WaitingDictionary 2.0.0

dotnet add package WaitingDictionary --version 2.0.0
                    
NuGet\Install-Package WaitingDictionary -Version 2.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="WaitingDictionary" Version="2.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="WaitingDictionary" Version="2.0.0" />
                    
Directory.Packages.props
<PackageReference Include="WaitingDictionary" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add WaitingDictionary --version 2.0.0
                    
#r "nuget: WaitingDictionary, 2.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package WaitingDictionary@2.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=WaitingDictionary&version=2.0.0
                    
Install as a Cake Addin
#tool nuget:?package=WaitingDictionary&version=2.0.0
                    
Install as a Cake Tool

WaitingDictionary

Represents a thread-safe collection of keys and TaskCompletionSource<T> as values, managed with two methods: WaitAsync and SetAsync.

NuGet Pre Release netstandard 2.1 CI

Image of Waiting Guy

What is it for

Sometimes a value produced on one thread/task must be delivered to a caller waiting on another. WaitingDictionary<T, V> is a rendezvous point for them:

  • WaitAsync(key) either returns a result that is already stored under the key, or parks the caller until someone provides it;
  • SetAsync(key, value) either delivers the value to the caller already waiting on the key, or stores it for the next one.

Typical uses: request coalescing (one in-flight fetch per key, all callers await it), signaling between producers and consumers, per-key async locks with a payload.

Installation

dotnet add package WaitingDictionary

Quick start

Just a mock model:

public class Mock
{
    public List<Mock> Nodes { get; }

    public Mock(params Mock[] mock)
    {
        (Nodes ??= new List<Mock>()).AddRange(mock);
    }

    public Mock()
    {
    }
}

Base example - wait first, set from somewhere else:

using var dict = new WaitingDictionary<int, Mock>();
// Starting waiting Task<Mock> (blocks until completed or cancelled) by key 1337
var result = await dict.WaitAsync(1337, CancellationToken.None);
//
//
// Some operations on other threads, or tasks..
//
//
// Set result by key 1337, it will release the previously started task that got it by WaitAsync
await dict.SetAsync(1337, new Mock());

Another example - set first, the value is stored until someone picks it up:

using var dict = new WaitingDictionary<int, Mock>();
// Set result by key 1337
await dict.SetAsync(1337, new Mock());
//
//
// Some operations on other threads, or tasks..
//
//
// Got result immediately
var result = await dict.WaitAsync(1337, CancellationToken.None);

Behavior rules

  • Automatic removal. SetAsync and WaitAsync automatically remove the element when it is completed. A stored result is consumed by WaitAsync exactly once.
  • One waiter per key. If a key already has a waiter in progress, the next WaitAsync throws InvalidOperationException. The exception surfaces as a faulted task, since WaitAsync is an async method.
  • One set per key. If a key already holds a completed result, the next SetAsync throws ArgumentException - unless a duplication middleware is registered.
  • Cancellation never poisons the key. When a wait is canceled by token (or by Dispose), the dead item is dropped; the following WaitAsync/SetAsync for the same key starts from a fresh item and works normally.
  • Disposal. Dispose cancels all pending waits, and Dispose is idempotent. Using the dictionary afterwards throws ObjectDisposedException. Register it as a singleton in your DI container.

API

Member Description
Task<TValue> WaitAsync(TKey key, CancellationToken token = default) Waits for (or immediately returns) the value stored by SetAsync.
Task SetAsync(TKey key, TValue value, bool ignoreError = false) Completes the pending wait or stores the value for the next one.
Task<bool> TryRemoveAsync(TKey key) Removes the item; a waiter on it is canceled.
IEnumerable<KVP> Filter(Func<KVP, bool> predicate) Snapshot-based enumeration with a predicate.
int Count / bool ContainsKey(TKey) / bool TryGetValue(TKey, out WaitItem<TValue>) Read-only inspection. The dictionary also implements IReadOnlyDictionary<TKey, WaitItem<TValue>>.

Note: WaitItem<TValue> exposes the underlying TaskCompletionSource<TValue> DelayedTask and the creation timestamp PlaceDateTime (UTC).

Stale items cleanup

A wait that never receives SetAsync and is never canceled would live forever. Set staleItemTimeToLiveMs to let a background job cancel and remove such items (the stored results age the same way):

// Items older than 30 seconds get canceled; cleanup runs every 30 seconds,
// so an item is cleared no earlier than 30 s and no later than 60 s after creation
using var dict = new WaitingDictionary<int, Mock>(staleItemTimeToLiveMs: 30_000);

MiddlewareBuilder

var middlewares =
    new MiddlewareBuilder<Mock>()
        // Will run on every completion SetAsync
        // Default: none
        .RegisterCompletionActionInSet(() => Console.WriteLine("Set completed"))
        // Will run on every completion WaitAsync
        // Default: none
        .RegisterCompletionActionInWait(() => Console.WriteLine("Wait completed"))
        // Will run on every duplicated element found while executing SetAsync
        // Default: throw exception
        .RegisterDuplicateActionInSet((old, @new) => new Mock(old, @new)) // merge two values
        // Will run on every WaitAsync cancellation
        // Default: TrySetCanceled
        // hasOwnToken == true: the wait used its own cancellable token,
        // false: the cancellation comes from Dispose()
        .RegisterCancellationActionInWait((tcs, hasOwnToken) => tcs.SetException(new Exception("Something went wrong")));

var dict = new WaitingDictionary<int, Mock>(middlewares);

The builder is captured as a snapshot when the dictionary is constructed: registering middleware afterwards does not affect an already created dictionary, and each registration can be done only once (second call throws InvalidOperationException).

Design and thread safety

  • ConcurrentDictionary makes single operations safe; a Nito.AsyncEx.AsyncLock makes the check-then-act sequences between WaitAsync, SetAsync, TryRemoveAsync and the cleanup job atomic as a whole.
  • Every TaskCompletionSource is created with RunContinuationsAsynchronously, so continuations of a waiter never run synchronously inside the setter's critical section.
  • The cleanup timer callback never throws into the thread pool and re-validates each item under the lock before touching it.

What's new in 2.0

Breaking changes:

  • IDictionary<TKey, …> / ICollection<…> implementations are removed (their members mostly threw NotSupportedException). The dictionary now implements only IReadOnlyDictionary<TKey, WaitItem<TValue>> + IDisposable; the indexer is read-only and throws KeyNotFoundException for a missing key.
  • Add, Clear and the dictionary-style Remove are gone - use SetAsync, TryRemoveAsync and Dispose.
  • WaitAsync no longer throws a stored cancellation into a new waiter, and SetAsync no longer throws InvalidOperationException on a canceled ghost item - dead items are replaced transparently.
  • The [Obsolete] constructors are removed; use the main constructor with optional arguments.
  • A canceled wait now also removes its item from the dictionary.

Fixes and improvements:

  • Stale-items cleanup no longer crashes the process when an item disappears concurrently, and no longer races with Wait/Set.
  • Dispose is idempotent and waits for a running cleanup callback.
  • TryRemoveAsync now cancels the orphaned waiter instead of letting it hang forever.
  • TaskCompletionSource is always created with RunContinuationsAsynchronously (avoids running waiter continuations under the internal lock; TaskCreationOptions passed to the constructor is preserved).
  • System.Runtime.CompilerServices.IsExternalInit polyfill allows modern C# records on netstandard2.1.

Development

dotnet build waiting-dictionary.sln -c Release
dotnet test waiting-dictionary.tests -c Release

Library targets netstandard2.1 (LangVersion latest), tests target net10.0 (NUnit 4).

Publishing (maintainers)

Every green push to master publishes the package to nuget.org via NuGet Trusted Publishing (OIDC) - there is no API key stored in the repository. The package version is taken from <Version> in waiting-dictionary.csproj.

To ship a new package: bump <Version> in the csproj, commit and push to master. The publish job in ci.yml runs after a successful build + test, exchanges the job's OIDC token for a short-lived API key with NuGet/login@v1, and pushes with --skip-duplicate.

One-time setup:

  1. On nuget.org: user menu → Trusted Publishing → Add policy: owner Drenalol, repository WaitingDictionary, workflow file ci.yml (filename only). Environment: leave empty unless the workflow uses environment:.
  2. In the GitHub repository: add a NUGET_USER secret (your nuget.org profile name, not email) and remove the obsolete NUGET_AUTH_TOKEN secret.

License

MIT (c) Bogdan Yanysh

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  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 was computed.  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 was computed.  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. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on WaitingDictionary:

Package Downloads
TcpClientIo

Wrapper of TcpClient what help focus on WHAT you transfer over TCP not HOW (Async, Thread-safe, TcpSerialization with attribute schema, Big/Little endian, Cancellation support)

StealthSharp.Network

This library contains a wrapper for Stealth

StealthSharp

This library contains a wrapper for Stealth

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.0 108 10/4/2026
1.1.4 7,939 5/20/2024
1.1.3 1,766 4/25/2024
1.1.2 351 4/19/2024
1.1.1 247 4/19/2024
1.1.0 248 4/19/2024
1.0.6 10,238 1/11/2021
1.0.5 6,701 1/5/2021
1.0.4 637 1/3/2021