AmbientServices 0.9.332
dotnet add package AmbientServices --version 0.9.332
NuGet\Install-Package AmbientServices -Version 0.9.332
<PackageReference Include="AmbientServices" Version="0.9.332" />
<PackageVersion Include="AmbientServices" Version="0.9.332" />
<PackageReference Include="AmbientServices" />
paket add AmbientServices --version 0.9.332
#r "nuget: AmbientServices, 0.9.332"
#:package AmbientServices@0.9.332
#addin nuget:?package=AmbientServices&version=0.9.332
#tool nuget:?package=AmbientServices&version=0.9.332
Overview
AmbientServices is a .NET library that provides abstractions for services which are both ubiquitous and optional, allowing assemblies that use it to be used in a variety of systems that provide vastly different implementations (or no implementation) of those services.
How this library is documented — The 5P Protocol
This project describes each unit of code — from a single class up to the whole library — with up to five short prose layers, the 5P: a Pitch (why you'd use it), a Pledge (what it promises and how you interact with it), a Plan (how it's built), a Pin (what may never change, for the units that freeze something), and a Priority (which consideration wins when two legal options compete). Per-type layers live in the XML-doc <remarks> of each type; the layers for the library as a whole live in their own documents:
- Pitch — is this library what you need?
- Pledge — the behavioral contract shared by every ambient service.
- Plan — how the ambient-service mechanism is built and the trade-offs it strikes.
- Priority — the rankings behind those trade-offs, and which way the library bends next time.
The methodology itself is defined in docs/MODULE_DESCRIPTIONS.md (with a C#-specific companion, docs/MODULE_DESCRIPTIONS.AmbientServices.md) and draws on a shared glossary.
Basic Services
The basic ambient services include local caching, an atomic cache, clock, logging, progress/cancellation, and settings. Interfaces for those services are provided here. By accessing these services through the interfaces provided here, library authors can utilize new basic services as they become available without changing their external interface, and library consumers can use those libraries without having to provide dependencies for systems that they may or may not use. If consumers want the added benefits provided by a more complicated implementation of one or more of those services, they can provide a bridge to their own implementations of these basic services and register them with the AmbientServices service. With one simple registration, the services will automatically be utilized by every library that uses AmbientServices.
The well known dependency injection pattern is one possible solution to this problem, but dependency injection requires the code consumer to pass the required dependencies to each object constructor (or worse, each function), which can be cumbersome. When the functionality is optional anyway, this can be more work than it's worth, especially when you're just trying to get things up and running quickly. Dependency injection becomes even more cumbersome when the assembly being used adds or removes service dependencies, requiring the consumer to update every constructor invocation with the new dependencies. Dependency injection still makes sense for services that are required, but when services are optional anyway, AmbientServices is a simpler option.
By convention, AmbientServices should not be used for information that alters the outputs of functions that use it in any way that the caller might care about or that don't behave like something that is normally environmental. Side-effects should either not alter the relationship between inputs and outputs at all, or should not alter them unexpectedly.
For example, logging and performance tracking should never alter function outputs at all. Caching may affect the output, but only by giving results that are slightly stale, and only in cases where there are already hidden inputs (like a database) anyway. Some functions may measure the passage of time during processing and might record that information or change their outputs based on the duration of time passed, but callers should not be surprised when the passage of time is slower or faster than their expected "normal." Settings (often stored in a configuration file) can alter the output of a function, but never in a way that the caller is concerned about. In fact, the very concept of settings is in reality a type of parameter intended to affect functions without requiring the caller to be concerned with their specific values. Progress tracking and cancellation may be useful for the caller, but never affects the output of the function other than aborting its processing altogether. Security context is an example of something that's often global that makes sense for AmbientServices because the fallback is to just use the environmental/global system. For example, explicitly passing the IAM Role around in AWS would be really painful, which is why the AWS SDK has rules for how it looks up the rights it uses, and those rules often involve multiple environmental or global credentials. Passing those credentials around is actually a bad idea.
Performance Services
Advanced ambient services provide detailed system performance monitoring.
There are three primary questions that may be answered through performance monitoring.
Q: How well are the various systems functioning under how much load?
A: This question may be answered using AmbientStatistics that track the usage, performance, and effectiveness of major system functions.
Q: Why did an operation take as long as it did?
A: This question may be answered using AmbientServiceProfiler to track which operations delayed the response by how much.
Q: How close is the system to maxing out?
A: This question may be answered using AmbientBottleneckDetector to track saturation of possible system bottlenecks so you can determine scalability even before load testing.
By using these services, with very little overhead, you can easily track how various parts of your system are performing all the time, not just when you run it with a code profiler. You can also expose this across the network to roll-up this data throughout the whole system, even up through the client.
Status
The status system enables periodic background testing of backend systems, with summarization of overall process status and across both heterogenous and homogenous server farms. Status tests are automatically detected based on class inheritance and constructor signature, but tests only run after the system is explicitly started.
Getting Started
In Visual Studio, use Manage Nuget Packages and search nuget.org for AmbientServices to add a package reference for this library.
For .NET Core environments, use:
dotnet add package https://www.nuget.org/packages/AmbientServices/
Service Descriptions
AmbientLocalCache
The ambient cache interface abstracts a very simple cache of the type that is universally applicable. Some items are cached for a specific amount of time, others are cached indefinitely. Items cached temporarily may have their expiration time extended or shortened each time they are retrieved or updated. Both types of items may expire from the cache at any time according to cache limits and/or memory capacity. Items may be removed from the cache manually at any time.
In order to prevent unexpected alteration of outputs, care must be taken to ensure that cached items are based entirely on the inputs. For functions that are not "pure" (database queries for example), the results should always be based entirely on the inputs and either the current state of the database or some previous state (when it uses cached results). For example, if the cache key does not contain all the inputs identifying the item being cached, completely different results could be obtained depending on the order in which calls to the cache were made. This is true of all caches and naturally every cache user and implementor understands that this type of usage is erroneous and must be avoided.
Disposable Entries
The local cache is the only cache in the library that may hold disposable entries (IDisposable or IAsyncDisposable), because its entries never leave the process: exactly one instance exists, it is the instance handed back, and so an owner can always be named. The caller picks the owner at store time with disposeWhenDiscarding:
true— the cache owns the entry and disposes it whenever it discards it (replaced by a later store under the same key, expired, evicted, or cleared). To avoid disposing something still in use, retrieval is a hand-off: the entry is removed from the cache and not disposed, so exactly one client holds it at a time.Removehands it back the same way.false— the caller owns the entry for its whole life. Retrieval is non-destructive and may hand the same instance to any number of callers, and the cache never disposes it, so an entry the cache silently discards leaks unless the caller still holds a reference. Treat such entries as shared mutable state.
Either way, if no cache service is in effect the store is simply discarded and the item is left undisposed for the caller to deal with, and an item the caching arguments rule out entirely is disposed only when the cache was given ownership of it. An entry that implements both disposal interfaces may be disposed through either one, so it must be safe to dispose either way; the default implementations prefer IAsyncDisposable and invoke exactly one of the two, on every target framework.
Disposable entries must never be stored in IAmbientSharedCache, AmbientTwoStageCache, or an IAmbientAtomicCache whose IsShared is true. Those entries may leave the process: storing serializes the item, so a later retrieval may hand back a fresh copy — here or on another machine — and neither side can name an owner for those copies. The store cannot know what a handle inside a blob refers to, and the caller never learns how many copies exist or when the last reader finished with one. These interfaces therefore promise nothing in either direction: a caller may neither rely on the cache disposing an entry nor assume it will leave one alone, since an in-process realization may well dispose what it evicts. The signatures reflect that — no disposeWhenDiscarding option, and Remove hands nothing back — so ownership never moves. Nothing enforces the rule (storing a disposable item raises no error); it fails later, as a leaked handle, a copy that deserialized into a useless one, or an ObjectDisposedException from a realization that disposed what it evicted.
The one exception is a type that implements a disposal interface without owning anything whose release matters — nothing unmanaged, pooled, or external — and that survives a serialization round trip intact, such as a MemoryStream-backed payload. Leaving such an entry undisposed costs nothing, so caching it is safe; the rule is about entries whose disposal is required for correctness.
Helpers
The AmbientLocalCache<TOWNER> generic class provides a wrapper of the ambient cache that attaches the owner type name as a prefix for each cache key to prevent cross-class cache key conflicts, and ignores calls when there is no ambient cache or it has been suppressed.
Settings
BasicAmbientLocalCache-EjectFrequency: the number of cache calls between cache ejections where at least one timed and one untimed entry is ejected from the cache. Default is 100. BasicAmbientLocalCache-ItemCount: the maximum number of both timed and untimed items to allow in the cache before ejecting items. Default is 1000.
Sample
/// <summary>
/// A user manager class that shows how the caching ambient service might be used.
/// </summary>
class UserManager
{
private static readonly AmbientLocalCache<UserManager> Cache = new();
/// <summary>
/// Finds the user with the specified email address.
/// </summary>
/// <param name="email">The emali address for the user.</param>
/// <returns>The <see cref="User"/>, if one was found, or null if the user was not found.</returns>
public static async ValueTask<User?> FindUser(string email)
{
string userKey = nameof(User) + "-" + email;
User? user = await Cache.Retrieve<User>(userKey, TimeSpan.FromMinutes(15));
if (user != null)
{
user = User.Find(email);
if (user != null) await Cache.Store<User>(userKey, user, false, TimeSpan.FromMinutes(15)); else await Cache.Remove<User>(userKey);
}
return user;
}
/// <summary>
/// Updates the specified user. (Presumably with a new password)
/// </summary>
/// <param name="user">The updated <see cref="User"/>.</param>
public static async ValueTask CreateUser(User user)
{
string userKey = nameof(User) + "-" + user.Email;
user.Create();
await Cache.Store<User>(userKey, user, false, TimeSpan.FromMinutes(15));
}
/// <summary>
/// Updates the specified user. (Presumably with a new password)
/// </summary>
/// <param name="user">The updated <see cref="User"/>.</param>
public static async ValueTask UpdateUser(User user)
{
string userKey = nameof(User) + "-" + user.Email;
user.Update();
await Cache.Store<User>(userKey, user, false, TimeSpan.FromMinutes(15));
}
/// <summary>
/// Deletes the specified user.
/// </summary>
/// <param name="email">The email of the user to be deleted.</param>
public static async ValueTask DeleteUser(string email)
{
string userKey = nameof(User) + "-" + email;
User.Delete(email);
await Cache.Remove<User>(userKey);
}
}
Default Implementation
The default implementation has a small local-only cache using a very simple implementation.
AmbientAtomicCache
The ambient atomic cache is an in-memory service for add-or-update style caching with optimistic concurrency.
Callers supply async factories; under contention a factory may run more than once, but only one result is retained for a given key.
Optional UTC expirations and refresh behavior are supported, along with monotonic versioned keys for tiered or split-cache patterns (see extension helpers on AmbientAtomicSplitCacheExtensions).
Remove affects only the unversioned slot for a logical key; VersionedRemove affects only the versioned slot, so both can coexist for the same key string.
Use this service when several threads might compute the same expensive result at once and you want a single winning writer without taking a process-wide lock.
For simple retrieve-or-store caching without races on creation, IAmbientLocalCache is often enough.
Disposable values may be cached only when IsShared is false; a shared realization serializes entries out of the process and so falls under the prohibition described above. A local realization takes dispose responsibility automatically — there is no opt-in flag, because discarding values is inherent to add-or-update under contention — and disposes a factory result that lost the race to install, an update that lost its compare-and-swap, a value the caching arguments ruled out, an entry displaced by a later write, and every entry it removes, ejects, or clears. That is what keeps a factory allocating a disposable from leaking one on every lost race. Retrieval, though, is never a hand-off: GetOrAdd and VersionedGet return the live instance and leave the entry installed, so the cache can dispose an instance a caller is still holding and that caller then sees an ObjectDisposedException. Cache disposable values here only when your use of them finishes inside the operation that fetched them, or when disposal mid-use is harmless; when an entry must survive in one owner's hands, use IAmbientLocalCache with disposeWhenDiscarding: true, which gives up the entry on the first hit for exactly that reason.
Settings
BasicAmbientAtomicCache-EjectFrequency: the number of cache calls between ejections where at least one timed and one untimed entry is ejected. Default is 100. BasicAmbientAtomicCache-MaximumItemCount: the maximum combined timed and untimed bookkeeping rows before ejection pressure applies. Default is 1000. BasicAmbientAtomicCache-MinimumItemCount: the minimum number of unexpired timed and untimed rows the implementation tries to retain. Default is 1.
Sample
/// <summary>
/// Example DTO for the atomic-cache README sample.
/// </summary>
public sealed class CachedReportSummary
{
/// <summary>Logical report identifier.</summary>
public string ReportId { get; init; } = "";
/// <summary>Example field produced by an expensive computation.</summary>
public int LineCount { get; init; }
}
/// <summary>
/// Demonstrates <see cref="IAmbientAtomicCache"/> for single-flight memoization: concurrent callers with the same key share one factory execution.
/// </summary>
public static class ReportSummaryCache
{
private static IAmbientAtomicCache? Cache => Ambient.GetService<IAmbientAtomicCache>().Local;
/// <summary>
/// Returns a cached <see cref="CachedReportSummary"/> for <paramref name="reportId"/>, or runs <paramref name="computeAsync"/> once to populate the cache.
/// </summary>
public static async ValueTask<CachedReportSummary> GetSummaryAsync(string reportId, Func<ValueTask<CachedReportSummary>> computeAsync, CancellationToken cancel = default)
{
IAmbientAtomicCache? cache = Cache;
if (cache is null)
{
return await computeAsync();
}
string key = nameof(CachedReportSummary) + "-" + reportId;
return await cache.GetOrAdd<CachedReportSummary>(
key,
async () =>
{
CachedReportSummary built = await computeAsync();
return (built, AmbientClock.UtcNow.AddMinutes(30));
},
timeout: TimeSpan.FromMinutes(2),
cancel: cancel);
}
}
Default Implementation
The default implementation is BasicAmbientAtomicCache, a concurrent in-process cache that uses AmbientClock for expirations and optimistic retry budgets.
Clear resets version counters and runs a small fixed number of snapshot-and-eject passes. It does not block other threads from installing entries; the intent is to dispose items that were already in the cache when clearing started (and any caught in a later pass), not to wait until the dictionary is empty. After a quiescent Clear, callers should see prior keys gone; under heavy concurrent caching, some new entries may remain.
AmbientLogger
The ambient logger interface abstracts a simple logging system of the type that is universally applicable. The logger simply receives strings to log and flushes them when called.
Logging should never be used in a way that affects control flow or results. The only side-effect should be transparent to the caller. Every user and implementor should understand this implied part of the logging interface contract. In order to do this, care should be taken to ensure that when using logging functions that use lambdas to avoid generating log messages until after the logging system checks to see if the message would be filtered, those lambdas must not have any side effects.
Helpers
The AmbientLogger<TOWNER> generic class provides a wrapper of the ambient cache that attaches the owner type, a severity level, and a category to each message and filters them according to settings from the ambient or specified settings. Overloads that take a message-generating lambda are also provided. These overloads should be used when generating the log message from the provided input data is expensive and the caller wants to avoid that expense when the message is going to be filtered anyway.
Settings
AmbientLogger-Format: A format string that controls what entries in the log look like where {0} is the entry time, {1} is the level, {2} is the log owner type, {3} is the category, and {4} is the message. Default is {0:yyMMdd HHmmss.fff} [{1}:{2}]{3}{4}.
AmbientLogFilter-LogLevel: The AmbientLogLevel above which logs will be filtered (entries at this level and below will be logged). Default is Information.
AmbientLogFilter-TypeAllow: A regular expression indicating which type(s) are allowed to be logged. Default is null, meaning all types are allowed.
AmbientLogFilter-TypeBlock: A regular expression indicating which type(s) should be blocked from being logged. Default is null, meaning no types should be blocked.
AmbientLogFilter-CategoryAllow: A regular expression indicating which categorie(s) are allowed to be logged. Default is null, meaning all categories are allowed.
AmbientLogFilter-CategoryBlock: A regular expression indicating which categorie(s) should be blocked from being logged. Default is null, meaning no categories should be blocked.
Blocking is applied after allowing, so if a type or category matches both expressions, it will be blocked.
Sample
/// <summary>
/// A static class with extensions methods used to log various assembly events.
/// </summary>
public static class AssemblyLoggingExtensions
{
private static readonly AmbientLogger<Assembly> Logger = new();
/// <summary>
/// Log that the assembly was loaded.
/// </summary>
/// <param name="assembly">The assembly that was loaded.</param>
public static void LogLoaded(this Assembly assembly)
{
Logger.Filter("Lifetime", AmbientLogLevel.Trace)?.Log(new { Action = "AssemblyLoaded", Assembly = assembly.FullName });
}
/// <summary>
/// Logs that there was an assembly load exception.
/// </summary>
/// <param name="assembly">The <see cref="AssemblyName"/> for the assembly that failed to load.</param>
/// <param name="ex">The <see cref="Exception"/> that occurred during the failed load.</param>
/// <param name="operation">The operation that needed the assembly.</param>
public static void LogLoadException(this AssemblyName assemblyName, Exception ex, string operation)
{
Logger.Error(ex, "Error loading assembly " + assemblyName.FullName + " while attempting to perform operation " + operation);
}
/// <summary>
/// Logs that an assembly was scanned.
/// </summary>
/// <param name="assembly">The <see cref="Assembly"/> that was scanned.</param>
public static void LogScanned(this Assembly assembly)
{
Logger.Filter("Scan", AmbientLogLevel.Trace)?.Log(new { Action = "AssemblyScanned", Assembly = assembly.FullName });
}
}
/// <summary>
/// A static class that does processing that logs to a rotating file instead of the default System.Diagnostics trace log.
/// </summary>
public static class MyProgram
{
private static readonly AmbientLogger<Assembly> Logger = new();
/// <summary>
/// Does the main processing.
/// </summary>
public static void Process()
{
using AmbientFileLogger bl = new();
using (new ScopedLocalServiceOverride<IAmbientLogger>(bl))
{
try
{
Logger.Filter("Process", AmbientLogLevel.Debug)?.Log(new { Action = "Sample Program Starting processing..." });
}
catch (Exception ex)
{
Logger.Error(ex, level: AmbientLogLevel.Critical);
}
}
}
}
Default Implementation
The default implementation logs messages to the System.Diagnostics.Trace system,
asynchronously buffers the log messages and flushes them in batches out to hourly rotating log files located in the system temp folder in files prefixed with "BasicAmbientLogger" and suffixed with the hour and a ".log" extension.
An alternate implementation, AmbientTraceLogger asynchronously logs messages to a performance-enhanced wrapper on the system debug trace output.
AmbientProgress
The ambient progress interface abstracts a simple context-following progress tracker of the type that is universally applicable. Progress tracking tracks the proportion of an operation that has completed processing and the item currently being processed and provides easy aggregation of subprocess progress. The ambient context is checked for cancellation each time the progress is updated or parts are started or completed.
Progress tracking should never affect control flow or results, except in the event of a cancellation, in which case there are no functional results. Naturally both consumers and services should avoid any usage or implementation to the contrary.
Helpers
The AmbientProgressService static class provides easy access to the local and global IAmbientProgress.
The AmbientCancellationTokenSource class is a superset of the framework's CancellationTokenSource that can raise cancellation using an ambient clock.
Settings
There are no settings for this service.
Sample
/// <summary>
/// A class that downloads and unzips a zip package.
/// </summary>
class DownloadAndUnzip
{
private readonly string _targetFolder;
private readonly string _downloadUrl;
private readonly MemoryStream _package;
public DownloadAndUnzip(string targetFolder, string downloadUrl)
{
_targetFolder = targetFolder;
_downloadUrl = downloadUrl;
_package = new MemoryStream();
}
public async ValueTask MainOperation(CancellationToken cancel = default)
{
IAmbientProgress? progress = AmbientProgressService.Progress;
using (progress?.TrackPart(0.01f, 0.75f, "Download "))
{
await Download();
}
using (progress?.TrackPart(0.75f, 0.99f, "Unzip "))
{
await Unzip();
}
}
#if NET5_0_OR_GREATER
public async ValueTask Download()
{
IAmbientProgress? progress = AmbientProgressService.Progress;
CancellationToken cancel = progress?.CancellationToken ?? default;
using HttpClient client = new();
using HttpResponseMessage response = await client.GetAsync(_downloadUrl);
long totalBytesRead = 0;
int bytesRead;
byte[] buffer = new byte[8192];
long contentLength = response.Content.Headers.ContentLength ?? 1000000;
using Stream downloadReader = await response.Content.ReadAsStreamAsync();
while ((bytesRead = await downloadReader.ReadAsync(buffer, 0, buffer.Length, cancel)) != 0)
{
await _package.WriteAsync(buffer, 0, bytesRead, cancel);
totalBytesRead += bytesRead;
progress?.Update(totalBytesRead * 1.0f / contentLength);
}
}
#else
public async ValueTask Download()
{
IAmbientProgress? progress = AmbientProgressService.Progress;
CancellationToken cancel = progress?.CancellationToken ?? default;
HttpWebRequest request = WebRequest.CreateHttp(_downlaodUrl);
using WebResponse response = request.GetResponse();
long totalBytesRead = 0;
int bytesRead;
long totalBytes = response.ContentLength;
byte[] buffer = new byte[8192];
using Stream downloadReader = response.GetResponseStream();
while ((bytesRead = await downloadReader.ReadAsync(buffer, 0, buffer.Length, cancel)) != 0)
{
await _package.WriteAsync(buffer, 0, bytesRead, cancel);
totalBytesRead += bytesRead;
progress?.Update(totalBytesRead * 1.0f / totalBytes);
}
}
#endif
public ValueTask Unzip()
{
IAmbientProgress? progress = AmbientProgressService.Progress;
CancellationToken cancel = progress?.CancellationToken ?? default;
using ZipArchive archive = new(_package);
int entries = archive.Entries.Count;
for (int entry = 0; entry < entries; ++entry)
{
ZipArchiveEntry archiveEntry = archive.Entries[entry];
// update the progress
progress?.Update(entry * 1.0f / entries, archiveEntry.FullName);
archiveEntry.ExtractToFile(Path.Combine(_targetFolder, archiveEntry.FullName));
}
return default;
}
}
Default Implementation
The default implementation tracks progress and provides access to the data, but does not output the progress information anywhere.
AmbientClock
The ambient clock interface abstracts a system clock. Artificial clock control can be important in testing, especially to efficiently and quickly exercise timeout conditions and to avoid timeouts when tests run under heavy CPU load (as you would usually want them to run in order to get through them as quickly as possible). Ideally, the underlying platform would provide some kind of thread or execution-context-specific clock for use by timeout logic, but unfortunately most platforms do not provide this functionality. This service provides that missing functionality, at least for the purposes of testing.
Clocks, of course, are generally counter to the goals of purely functional programming, and even in imperative programming, it makes sense that functions that aren't obviously time-dependent should not have their outputs unexpectedly affected by the clock. One such acceptable usage is logging with timestamps. Another acceptable usage is timeouts. For all programs, clocks could indirectly appear to be frozen if the CPU is unexpectedly fast or the system clock has an unexpectedly low resolution. Correspondingly, clocks could appear to skip ahead if the system CPU is overloaded and the thread doesn't get scheduled or if the system goes to sleep or hibernates and then later resumes. The artificial clock AmbientClock provides simply allows an upstream service consumer to simulate those conditions for both unit and integration testing purposes. These are important edge cases to test for systems that need a high degree of reliability and graceful degradation.
Clocks should never go backwards. Ambient clock service implementors must ensure this holds true.
Helpers
The AmbientClock static class provides an abstraction that automatically uses the system clock if there is no registered clock service. It also provides a Pause function that allows the caller to temporarily pause time as seen by the ambient clock. The SkipAhead function allows the caller to move the paused clock forward (ignored if the clock is not paused). AmbientClock can also issue an AmbientCancellationToken that is cancelled by the ambient clock service.
The AmbientStopwatch class provides a time measuring class similar to the framework's Stopwatch class, but pauses when the ambient clock is paused.
The AmbientTimer class provides a callback similar to the framework's Timer class, but follows the ambient clock.
Usage
Converting a project to use AmbientClock begins with changing all references to DateTime.UtcNow to AmbientClock.UtcNow, Stopwatch to AmbientStopwatch, and System.Timers.Timer to AmbientTimer. This should not affect anything at all. Next, in unit tests that are sensitive to timing, add the following code:
using (AmbientClock.Pause())
{
// beginning of test here--no time will appear to pass while this code executes
// move the ambient clock ahead such that it will appear to the system that exactly 100ms has passed
AmbientClock.SkipAhead(TimeSpan.FromMilliseconds(100));
// more test code here--no time will appear to pass while this code executes, but 100ms will appear to have elapsed since the first part of the code ran
}
Sample
/// <summary>
/// A test for TimeDependentService.
/// </summary>
[TestClass]
public class TimeDependentServiceTest
{
[TestMethod]
public async Task TestCancellation()
{
// this first part *should* get cancelled because we're using the system clock
using AmbientCancellationTokenSource cts = new(TimeSpan.FromSeconds(1));
await Assert.ThrowsAsync<OperationCanceledException>(() => AsyncFunctionThatShouldCancelAfterOneSecond(cts.Token).AsTask());
// switch the current call context to the artificially-paused ambient clock and try again
using (AmbientClock.Pause())
{
using AmbientCancellationTokenSource cts2 = new(TimeSpan.FromSeconds(1));
// this should *not* throw because the clock has been paused
await AsyncFunctionThatShouldCancelAfterOneSecond(cts2.Token);
// this skips the artificial paused clock ahead, raising the cancellation
AmbientClock.SkipAhead(TimeSpan.FromSeconds(1));
// make sure the cancellation got raised
Assert.Throws<OperationCanceledException>(() => cts2.Token.ThrowIfCancellationRequested());
}
}
private async ValueTask AsyncFunctionThatShouldCancelAfterOneSecond(CancellationToken cancel)
{
for (int loop = 0; loop < 20; ++loop)
{
await Task.Delay(100);
cancel.ThrowIfCancellationRequested();
}
}
[TestMethod]
public async Task TestCodeThatCouldTimeoutUnderHeavyLoad()
{
using (AmbientClock.Pause())
{
using AmbientCancellationTokenSource cts = new(TimeSpan.FromSeconds(1));
await AsyncFunctionThatCouldTimeoutUnderHeavyLoad(cts.Token);
}
}
private async ValueTask AsyncFunctionThatCouldTimeoutUnderHeavyLoad(CancellationToken cancel)
{
AmbientStopwatch stopwatch = new(true);
for (int count = 0; count < 9; ++count)
{
await Task.Delay(100);
cancel.ThrowIfCancellationRequested();
}
// if we finished before getting cancelled, we must have been scheduled within about 10 milliseconds on average, or we must be running using a paused ambient clock
}
}
Default Implementation
There is no default implementation, but an implementation intended for use as a local override is provided. The lack of default implementation causes the helper classes to use the system clock.
AmbientSettings
The ambient settings interface abstracts a simple string-based settings set. Each setting has a value identified by a unique string. The value of the setting is always a string, but each setting may be converted to a desired type by specifying a delegate that converts the string into the desired strongly-typed value.
Often the value for a setting may change on the fly, so the value exposed by the helper class might change after initialization. Users can also subscribe to an event that notifies them when the value for a setting changes, in case they need to do something more complicated than just parsing the new value. Value change event notifications may arrive asynchronously on any thread at any time, so users must not depend on the notification occurring before they get an updated value.
A call-context-specific override can be used for some settings, but of course no change notifications can occur when the value changes due to setting a call-context-local settings set or changes of the value within a call-context-local settings set (where would the notification go?).
Settings set implementations may or may not provide post-initialization settings value updates but if they do, they should also raise the notifications.
Among other things, the ambient settings system is designed to provide sensible access to settings and notification of changes during system startup and shutdown.
For example, at the beginning of startup, the settings just use default values.
At some point, the global settings set can be replaced with a settings set implementation that reads from a local configuration, and then later on with an implementation that reads settings from a centralized settings store.
Users of settings don't need to bother with knowing where the settings come from, only that they might change during system startup.
This is especially useful for things like logging.
Errors that occur before the location of shared logs is determined (that location might be stored in a central database) can be stored in the event log or local file system as desired.
Once the centralized settings are hooked up, logging can automatically switch to a remote log store indicated in the centralized settings store.
No centralized (and often complicated) "startup" code is required for this kind of transition, just a subscription to the change event for a log configuration setting.
Most code can (and usually should) use the default ambient settings set, which will automatically transition from basic settings sets implementations to more complicated ones as initialization progresses and more complicated implementations become available for use.
Settings by their very nature must be considered inputs for the purposes of functional programming.
They are by definition not passed on the stack (otherwise, they're just insanely-overpopulated collections of parameters someone decided to call "settings").
Helpers
The IAmbientSetting<T> generic helper interface provides access to a type-converted setting and an event to notify subscribers when the setting value changes.
The AmbientSettings static class is used to construct an IAmbientSetting<T> for the caller.
Settings provided by AmbientSettings can be "settings set" settings whose value comes from an explicit settings set specified during construction, or "ambient" settings whose value comes from the default ambient settings set (even if there is a local override in the call-context when the value is retrieved).
Settings
There are no settings for this service.
Sample
/// <summary>
/// A class that manages a pool of buffers.
/// </summary>
class BufferPool
{
private static readonly IAmbientSetting<int> MaxTotalBufferBytes = AmbientSettings.GetAmbientSetting<int>(nameof(BufferPool) + "-MaxTotalBytes", "The maximum total number of bytes to use for all the allocated buffers. The default value is 1MB.", s => Int32.Parse(s), "1000000");
private static readonly IAmbientSetting<int> DefaultBufferBytes = AmbientSettings.GetAmbientSetting<int>(nameof(BufferPool) + "-BufferBytes", "The number of bytes to allocate for each buffer. The default value is 8000 bytes.", s => Int32.Parse(s), "8000");
private readonly SizedBufferRecycler _recycler; // interlocked
class SizedBufferRecycler
{
private readonly ConcurrentBag<byte[]> _bag;
public SizedBufferRecycler(int bufferBytes)
{
BufferBytes = bufferBytes;
_bag = new ConcurrentBag<byte[]>();
}
public int BufferBytes { get; }
public byte[] GetBuffer(int bytes)
{
if (bytes < BufferBytes)
{
byte[]? buffer;
if (_bag.TryTake(out buffer))
{
return buffer;
}
}
return new byte[Math.Max(bytes, BufferBytes)];
}
public void Recycle(byte[] buffer)
{
if (buffer.Length == BufferBytes && _bag.Count * BufferBytes < MaxTotalBufferBytes.Value)
{
_bag.Add(buffer);
}
// else just let the garbage collector release this byte array
}
}
/// <summary>
/// Constructs a buffer pool using the ambient settings.
/// </summary>
public BufferPool()
{
_recycler = new SizedBufferRecycler(DefaultBufferBytes.Value);
}
/// <summary>
/// Gets a buffer from the recycling pool.
/// </summary>
/// <param name="minimumByteSize">The minimum buffer size.</param>
/// <returns>A buffer with at least as many bytes as specified.</returns>
public byte[] GetBuffer(int minimumByteSize)
{
SizedBufferRecycler recycler = _recycler;
return recycler.GetBuffer(minimumByteSize);
}
/// <summary>
/// Recycles a previously-retrieved buffer by putting it back into the pool, if that's where it belongs.
/// </summary>
/// <param name="buffer">The buffer to return to the pool</param>
public void RecycleBuffer(byte[] buffer)
{
SizedBufferRecycler recycler = _recycler;
recycler.Recycle(buffer);
}
}
Default Implementation
The default implementation just uses a local initially-empty ConcurrentDictionary to keep track of settings values, so the default settings values will be used unless the default settings set is altered.
Some analysis tools encourage splitting IAmbientSettingsSet into a base interface that only reads and a derived interface that adds writing.
This turns out to greatly complicate the system. For example, with derived interfaces, how do you handle the ambient instance?
Do you force both to use the same instance? Is that what you always want? What if you have an implementation that only implements the base interface?
If you allow separate instances, you end up with settings that write to one instance but read from another, which is usually not what you want.
Following the example of .NET Stream, I've chosen to use a single interface that provides both reading and writing, but only if the implementation supports it.
I at least provide a function that lets you know whether you can write or not, so you don't have to attempt to write and get an exception.
Implementations are provided for settings backed by the system environment (AmbientEnvironmentSettingsSet), an in-memory set (BasicAmbientSettingsSet), an immutable in-memory set (AmbientImmutableSettingsSet), app.config settings (AppConfigSettingsSet), a fake settings set attached to default settings (DefaultSettingsSet), and a layered settings set that allows you to combine multiple settings sets into a single set (AmbientSettingsLayers).
Customizing Ambient Services
Implementing A New Ambient Service
/// <summary>
/// An interface that abstracts a simple ambient call stack tracking service.
/// </summary>
public interface IAmbientCallStack
{
/// <summary>
/// Creates a call stack scope for the specified function name, keeping it on the stack until it is disposed.
/// </summary>
/// <param name="function">The name of the function being executed.</param>
/// <returns>A disposable object that will remove the function at the end of its execution scope.</returns>
IDisposable Scope(string function);
/// <summary>
/// Gets an enumeration of the call stack entries created by previous calls to <see cref="Scope"/> that haven't yet been disposed.
/// </summary>
IEnumerable<string> Entries { get; }
}
/// <summary>
/// A basic implementation of <see cref="IAmbientCallStack"/>.
/// A few enhancements could make these call stacks accessible remotely, which could be very handy for diagnosing what servers are busy doing.
/// </summary>
[DefaultAmbientService]
class BasicAmbientCallStack : IAmbientCallStack
{
private static readonly AsyncLocal<ImmutableStack<string>> Stack = new();
private static ImmutableStack<string> GetStack()
{
ImmutableStack<string>? stack = Stack.Value;
if (stack == null)
{
stack = ImmutableStack<string>.Empty;
Stack.Value = stack;
}
return stack;
}
public IDisposable Scope(string entry)
{
ImmutableStack<string> stack = GetStack();
stack = stack.Push(entry);
Stack.Value = stack;
return new CallStackEntry(stack);
}
public IEnumerable<string> Entries => GetStack();
class CallStackEntry : IDisposable
{
private ImmutableStack<string>? _stack;
public CallStackEntry(ImmutableStack<string> stack)
{
_stack = stack;
}
private bool _disposedValue = false;
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
if (_stack != null)
{
Stack.Value = _stack.Pop();
_stack = null;
}
}
_disposedValue = true;
}
}
public void Dispose()
{
Dispose(true);
}
}
}
Disabling An Ambient Service
/// <summary>
/// A sample setup class that disables the cache implementation when it is initialized.
/// </summary>
class Setup
{
private static readonly AmbientService<IAmbientLocalCache> Cache = Ambient.GetService<IAmbientLocalCache>();
static Setup()
{
Cache.Global = null;
}
}
Overriding An Ambient Service Globally
/// <summary>
/// An application setup class that registers an implementation of <see cref="IAmbientSettingsSet"/> that uses <see cref="Configuration.AppSettings"/> for the settings as the ambient service.
/// </summary>
class SetupApplication
{
static SetupApplication()
{
AmbientService<IAmbientSettingsSet> SettingsSet = Ambient.GetService<IAmbientSettingsSet>();
SettingsSet.Global = new AppConfigAmbientSettings();
}
}
/// <summary>
/// An implementation of <see cref="IAmbientSettingsSet"/> that uses <see cref="Configuration.AppSettings"/> as the backend settings store.
/// </summary>
class AppConfigAmbientSettings : IAmbientSettingsSet
{
public string SetName => "AppConfig";
public string? GetRawValue(string key)
{
return System.Configuration.ConfigurationManager.AppSettings[key];
}
public object? GetTypedValue(string key)
{
string? rawValue = System.Configuration.ConfigurationManager.AppSettings[key];
IAmbientSettingInfo? ps = SettingsRegistry.DefaultRegistry.TryGetSetting(key);
return (ps != null) ? ps.Convert(this, rawValue ?? "") : rawValue;
}
/// <summary>
/// Gets whether or not the settings set is mutable.
/// </summary>
public bool SettingsAreMutable => false;
/// <summary>
/// Changes the specified setting, if possible.
/// For many ambient settings services, the value will only be reflected in memory until the process shuts down, but other services may persist the change.
/// </summary>
/// <param name="key">A string that uniquely identifies the setting.</param>
/// <param name="value">The new string value for the setting, or null if the setting should be removed.</param>
/// <returns>Whether or not the setting actually changed (it may have had already the same value).</returns>
public bool ChangeSetting(string key, string? value) => throw new InvalidOperationException($"{nameof(AppConfigAmbientSettings)} is not mutable.");
}
Overriding An Ambient Service Locally From A Call Context
/// <summary>
/// An implementation of <see cref="IAmbientSettingsSet"/> that overrides specific settings.
/// </summary>
class LocalAmbientSettingsOverride : IAmbientSettingsSet, IDisposable
{
private static readonly AmbientService<IAmbientSettingsSet> SettingsSet = Ambient.GetService<IAmbientSettingsSet>();
private readonly IAmbientSettingsSet? _oldSettingsSet;
private readonly Dictionary<string, string> _overrides;
/// <summary>
/// For the life of this instance, overrides the settings in the specified dictionary with their corresponding values.
/// </summary>
/// <param name="overrides">A Dictionary containing the key/value pairs to override.</param>
public LocalAmbientSettingsOverride(Dictionary<string, string> overrides)
{
_oldSettingsSet = SettingsSet.Local;
SettingsSet.Override = this;
_overrides = new Dictionary<string, string>();
}
public string SetName => nameof(LocalAmbientSettingsOverride);
/// <summary>
/// Disposes of this instance, returning the ambient settings to their former value.
/// </summary>
public void Dispose()
{
SettingsSet.Override = _oldSettingsSet;
}
public string? GetRawValue(string key)
{
string? value;
if (_overrides.TryGetValue(key, out value))
{
return value;
}
return _oldSettingsSet?.GetRawValue(key);
}
public object? GetTypedValue(string key)
{
string? rawValue = GetRawValue(key);
IAmbientSettingInfo? ps = SettingsRegistry.DefaultRegistry.TryGetSetting(key);
return (ps != null) ? ps.Convert(this, rawValue ?? "") : rawValue;
}
/// <summary>
/// Gets whether or not the settings set is mutable.
/// </summary>
public bool SettingsAreMutable => false;
/// <summary>
/// Changes the specified setting, if possible.
/// For many ambient settings services, the value will only be reflected in memory until the process shuts down, but other services may persist the change.
/// </summary>
/// <param name="key">A string that uniquely identifies the setting.</param>
/// <param name="value">The new string value for the setting, or null if the setting should be removed.</param>
/// <returns>Whether or not the setting actually changed (it may have had already the same value).</returns>
public bool ChangeSetting(string key, string? value) => throw new InvalidOperationException($"{nameof(LocalAmbientSettingsOverride)} is not mutable.");
}
AmbientStatistics
The IAmbientStatistics interface abstracts the creation and gathering of statistics.
Each statistic keeps track of the measurement of one aspect of system performance, using a single number that holds an accumulated, minimum, maximum, or raw value.
Statistics can be used to track memory allocated, time waited, minimum or maximum sizes or times, cache hits and misses, etc.
Each statistic can be incremented or decremented, added-to, set to a new raw value, or conditionally set if it is a new minimum or maximum value.
Ratios of two statistics or their changes can be used to track things like average sizes or times, events per second, bytes per second, cache hit ratios, etc.
A statistic named "ExecutionTime" is defined by the system and holds the number of ticks elapsed since the process started.
Ticks are in terms of the standard system Stopwatch.Frequency. All operations are lock-free. Min and Max statistics use an optimistic atomic update loop.
Helpers
The IAmbientStatisticReader interface provides read access to an individual statistic.
The IAmbientStatistic interface extends IAmbientStatisticReader interface and adds functions to update the value for the statistic.
Sample
/// <summary>
/// A class that represents a type of request.
/// </summary>
public class RequestType
{
private static readonly AmbientService<IAmbientStatistics> AmbientStatistics = Ambient.GetService<IAmbientStatistics>();
/// <summary>
/// Constructs a RequestType with the specified type name.
/// </summary>
/// <param name="typeName">The name of the request type.</param>
public RequestType(string typeName)
{
IAmbientStatistics? ambientStatistics = AmbientStatistics.Local;
PendingRequests = ambientStatistics?.GetOrAddStatistic(AmbientStatisticType.Raw, typeName + "-RequestsPending", "Pending Requests", "The number of requests currently executing", false, 0, null, null, "", 1.0);
TotalRequests = ambientStatistics?.GetOrAddStatistic(AmbientStatisticType.Cumulative, typeName + "-TotalRequests", "Total Requests", "The total number of requests that have finished executing", false, 0, null, null, "", 1.0);
TotalProcessingTime = ambientStatistics?.GetOrAddStatistic(AmbientStatisticType.Cumulative, typeName + "-TotalProcessingTime", "Total Processing Time", "The total time spent processing requests (only includes completed requests)", false, 0, null, null, "seconds", Stopwatch.Frequency);
Retries = ambientStatistics?.GetOrAddStatistic(AmbientStatisticType.Cumulative, typeName + "-Retries", "Retries", "The total number of retries", false, 0, null, null, "", 1.0);
Failures = ambientStatistics?.GetOrAddStatistic(AmbientStatisticType.Cumulative, typeName + "-Failures", "Failures", "The total number of failures", false, 0, null, null, "", 1.0);
Timeouts = ambientStatistics?.GetOrAddStatistic(AmbientStatisticType.Cumulative, typeName + "-Timeouts", "Timeouts", "The total number of timeouts", false, 0, null, null, "", 1.0);
}
/// <summary>
/// Tracks a request by creating a <see cref="RequestTracker"/> which automatically counts the request and times its duration and allows the caller to report failures, timeouts, and retries.
/// </summary>
/// <returns>A <see cref="RequestTracker"/> instance that should be disposed when the request finishes processing.</returns>
public RequestTracker TrackRequest()
{
return new RequestTracker(this);
}
/// <summary>
/// Gets the <see cref="IAmbientStatistic"/> that tracks the number of pending requests.
/// </summary>
public IAmbientStatistic? PendingRequests { get; }
/// <summary>
/// Gets the <see cref="IAmbientStatistic"/> that tracks the total number of requests.
/// </summary>
public IAmbientStatistic? TotalRequests { get; }
/// <summary>
/// Gets the <see cref="IAmbientStatistic"/> that tracks the total processing time.
/// </summary>
public IAmbientStatistic? TotalProcessingTime { get; }
/// <summary>
/// Gets the <see cref="IAmbientStatistic"/> that tracks the total number of retries.
/// </summary>
public IAmbientStatistic? Retries { get; }
/// <summary>
/// Gets the <see cref="IAmbientStatistic"/> that tracks the total number of failures.
/// </summary>
public IAmbientStatistic? Failures { get; }
/// <summary>
/// Gets the <see cref="IAmbientStatistic"/> that tracks the total number of timeouts.
/// </summary>
public IAmbientStatistic? Timeouts { get; }
}
/// <summary>
/// A request tracking object.
/// </summary>
public class RequestTracker : IDisposable
{
private readonly RequestType _requestType;
private readonly AmbientStopwatch _stopwatch;
private bool _disposedValue;
internal RequestTracker(RequestType requestType)
{
_requestType = requestType;
_stopwatch = new AmbientStopwatch(true);
requestType.PendingRequests?.IncrementRaw();
}
/// <summary>
/// Reports a failure during the processing of the request.
/// </summary>
public void ReportFailure()
{
_requestType.Failures?.IncrementRaw();
}
/// <summary>
/// Reports a timeout during the processing of the request.
/// </summary>
public void ReportTimeout()
{
_requestType.Timeouts?.IncrementRaw();
}
/// <summary>
/// Reports a retry during the processing of the request.
/// </summary>
public void ReportRetry()
{
_requestType.Retries?.IncrementRaw();
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_requestType.PendingRequests?.AddRaw(-1);
_requestType.TotalRequests?.IncrementRaw();
_requestType.TotalProcessingTime?.AddRaw(_stopwatch.ElapsedTicks);
}
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
// TODO: set large fields to null
_disposedValue = true;
}
}
// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~RequestTracker()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
/// <summary>
/// Disposes of the RequestTracker, decrementing the pending request count and adding the time to the total time statistic.
/// </summary>
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
/// <summary>
/// A static class to report statistics in XML format.
/// </summary>
public static class StatisticsReporter
{
private static readonly AmbientService<IAmbientStatistics> AmbientStatistics = Ambient.GetService<IAmbientStatistics>();
/// <summary>
/// Writes all statistics with their current values to the specified <see cref="XmlWriter"/>.
/// </summary>
/// <param name="writer">The <see cref="XmlWriter"/> to write the data to.</param>
public static void ToXml(XmlWriter writer)
{
writer.WriteStartElement("statistics");
foreach (IAmbientStatisticReader statistic in AmbientStatistics.Local?.Statistics.Values ?? Array.Empty<IAmbientStatisticReader>())
{
writer.WriteStartElement("statistic");
writer.WriteAttributeString("id", statistic.Id);
writer.WriteAttributeString("value", statistic.CurrentRawValue.ToString());
writer.WriteEndElement();
}
writer.WriteEndElement();
}
}
Default Implementation
The default implementation uses thread-safe lock-free statistics instances, keeping all the information associated with each statistic.
AmbientServiceProfiler
The AmbientServiceProfiler interface abstracts a low-overhead service profiler with performance designed for always-on coarse-grained profiling.
This profiling can be used to determine how the time for a request, program, or time window was used.
The code being profiled calls into the IAmbientServiceProfiler each time the system that is executing switches (only one system can be active per call context at a time).
A system identifier contains a main system name followed by various subsystem and result identifiers (of course results aren't available until the next system begins executing, so the profiler allows the service to update the system identifier after execution completes).
The consumer of the service profiler may want to ignore some or all of the subsystem and result parts of the identifier and can do so using the system group transform setting, which is a regular expression that matches only the desired pieces of the identifier, causing statistics from one or more subsystems and/or results to be grouped together.
For example, a system identifier might be SQL/Database:My-database/Table:User/Result:Failed.
The fully-detailed system identifier would allow the service profile consumer to distinguish how much time was spent waiting for SQL results that failed from those that timed out or succeeded, and those from one database and/or table from another.
This level of information is usually too-detailed, so the consumer may want to group everything to just the top-level system, in which case, all SQL access, no matter which database or table, and no matter whether the operation was successful, timed out, or threw an exception, would all be grouped into a single profile entry.
When no other system is executing, the service should set the system identifier to the empty string, which will also be tracked.
Some systems may allow tracking of CPU time, so that could be another system identifier.
As of .NET 8, it does not provide any way to track this, so the consumer can assume that the empty string system accounts for any remaining CPU time.
Of course, this estimate will be wildly incorrect if the service, while running under the empty string system, calls something that blocks execution (such as waiting for a mutex or performing IO), or when the system CPU is high enough that available threads don't get scheduled.
Helpers
The AmbientServiceProfilerCoordinator allows users to create service profilers for various contexts, including the current call context, rotating time windows of a given time span, or the process as a whole.
The call context profiler and process-wide profiler implement the IAmbientServiceProfile interface, and the time window profiler calls an async delegate with an instance of that interface, each contains the profile for the context it came from.
IAmbientServiceProfile provides access to a scope name and and enumeration of AmbientServiceProfilerAccumulator instance, each of which has the statistics for a given system or system group.
Settings
AmbientServiceProfilerCoordinator-DefaultSystemGroupTransform: A Regex string used to transform the system identifier to a group identifier.
The regular expression will attempt to match the system identifier, with the values for any matching match groups being concatenated into the system group identifier.
Sample
/// <summary>
/// A class that access a SQL database and reports profiling information to the system profiling system.
/// </summary>
class SqlAccessor
{
private static readonly AmbientService<IAmbientServiceProfiler> ServiceProfiler = Ambient.GetService<IAmbientServiceProfiler>();
private readonly string _connectionString;
private readonly SqlConnection _connection;
private readonly string _systemIdPrefix;
/// <summary>
/// Creates a SQL accessor for the specified connection string.
/// </summary>
/// <param name="connectionString">A connection string with information on how to connect to a SQL Server database.</param>
public SqlAccessor(string connectionString)
{
_connectionString = connectionString;
_connection = new SqlConnection(connectionString);
_systemIdPrefix = $"SQL/Server:{_connection.DataSource}/Database:{_connection.Database}";
}
/// <summary>
/// Creates a <see cref="SqlCommand"/> that uses this connection.
/// </summary>
/// <returns>A <see cref="SqlCommand"/> for this connection.</returns>
public SqlCommand CreateCommand() { return _connection.CreateCommand(); }
private async Task<T> ExecuteAsync<T>(SqlCommand command, Func<CancellationToken, Task<T>> f, string? table = null, CancellationToken cancel = default)
{
string systemId = _systemIdPrefix + (string.IsNullOrEmpty(table) ? "" : $"/Table:{table}");
T ret;
try
{
ServiceProfiler.Local?.SwitchSystem(systemId);
ret = await f(cancel);
systemId += $"/Result:Success";
}
catch (Exception e)
{
if (e.Message.ToUpperInvariant().Contains("TIMEOUT")) systemId += $"/Result:Timeout";
else systemId += $"/Result:Error";
throw;
}
finally
{
ServiceProfiler.Local?.SwitchSystem("", systemId);
}
return ret;
}
public async ValueTask<int> ExecuteNonQueryAsync(SqlCommand command, CancellationToken cancel = default, string? table = null)
{
return await ExecuteAsync<int>(command, command.ExecuteNonQueryAsync, table, cancel);
}
public async ValueTask<SqlDataReader> ExecuteReaderAsync(SqlCommand command, CancellationToken cancel = default, string? table = null)
{
return await ExecuteAsync<SqlDataReader>(command, command.ExecuteReaderAsync, table, cancel);
}
public async ValueTask<object> ExecuteScalarAsync(SqlCommand command, CancellationToken cancel = default, string? table = null)
{
return await ExecuteAsync<object>(command, command.ExecuteScalarAsync, table, cancel);
}
public async ValueTask<XmlReader> ExecuteXmlReaderAsync(SqlCommand command, CancellationToken cancel = default, string? table = null)
{
return await ExecuteAsync<XmlReader>(command, command.ExecuteXmlReaderAsync, table, cancel);
}
}
/// <summary>
/// A class that collects bottleneck statistics and reports on them.
/// </summary>
class ProfileReporter
{
private Dictionary<string, long>? _mostRecentWindowServiceProfile; // interlocked
private readonly AmbientServiceProfilerCoordinator _coordinator;
private readonly IDisposable? _timeWindow;
/// <summary>
/// Constructs a Bottleneck reporter that holds onto the top ten utilized bottlenecks for the entire process for the previous one-minute window.
/// </summary>
public ProfileReporter()
{
_coordinator = new AmbientServiceProfilerCoordinator();
_timeWindow = _coordinator.CreateTimeWindowProfiler(nameof(ProfileReporter), TimeSpan.FromMilliseconds(100), OnMostRecentWindowClosed);
}
private Task OnMostRecentWindowClosed(IAmbientServiceProfile profile)
{
Dictionary<string, long> serviceProfile = new();
foreach (AmbientServiceProfilerAccumulator record in profile.ProfilerStatistics)
{
serviceProfile.Add(record.Group, record.TotalStopwatchTicksUsed);
}
Interlocked.Exchange(ref _mostRecentWindowServiceProfile, serviceProfile);
return Task.CompletedTask;
}
/// <summary>
/// Gets a dictionary containing the service profile for the most recent time window.
/// </summary>
public Dictionary<string, long>? RecentProfile => _mostRecentWindowServiceProfile;
}
Default Implementation
The default implementation uses thread-safe lock-free instances. Each system switch is transformed according to the setting and then distributed to all the profilers the switch applies to.
AmbientBottleneckDetector
The IAmbientBottleneckDetector interface provides access to a function to measure access to a bottleneck and events that are used to track usage over time.
The gathered data can be used to determine how close that part of the system is to maxing-out, so that scalability limits can be more accurately estimated.
Helpers
An instance of the AmbientBottleneck class is used to represent each bottleneck in the system.
Each bottleneck has a unique string identifier, a description, an algorithm indicating how blocking occurs, and an optional limit and time window for that limit.
When code enters the bottleneck, it calls EnterBottleneck on the AmbientBottleneck instance.
This function returns an AmbientBottleneckAccessor instance which scopes access to the bottleneck.
The Automatic property on AmbientBottleneck identifies whether or not the timing of the scope of the AmbientBottleneckAccessor instance automatically sets the bottleneck usage, or whether the usage is set manually using SetUsage and/or AddUsage on the AmbientBottleneckAccessor instance.
Note that bottlenecks will sometimes overlap, such that multiple bottlenecks have been entered at the same time, but users of the system should be sure that if the bottlenecks are associated with exclusive access, such as a mutex, that in order to avoid deadlock, entry to such bottlenecks should be strictly ordered.
The AmbientBottleneckSurveyorCoordinator class provides access to surveyors for various contexts such as the current call context, the entire process, the current thread, and/or a rotating time window.
The surveyor coordinator collects the bottleneck usage events and distributes them to each of the applicable surveyors that have been created so they can track access within their context and provide survey results.
Settings
AmbientBottleneckSurveyorCoordinator-DefaultAllow: A Regex string used to match bottleneck identifiers that should be tracked. By default, all bottlenecks are allowed.
AmbientBottleneckSurveyorCoordinator-DefaultBlock: A Regex string used to match bottleneck identifiers that should NOT be tracked. By default, no bottlenecks are blocked.
Blocking is applied after allowing, so if a bottleneck matches both expressions, it will be blocked.
Sample
/// <summary>
/// A class that holds a thread-safe queue which reports on the associated bottleneck.
/// </summary>
class GlobalQueue
{
private static readonly Mutex Mutex = new(false);
private static readonly Queue<object> Queue = new();
private static readonly AmbientBottleneck GlobalQueueBottleneck = new("GlobalQueue-Access", AmbientBottleneckUtilizationAlgorithm.Linear, true, "A bottleneck which only allows one accessor at a time.");
/// <summary>
/// Adds a new item to the queue.
/// </summary>
/// <param name="o">The object to add to the queue.</param>
public static void Enqueue(object o)
{
try
{
Mutex.WaitOne();
using (GlobalQueueBottleneck.EnterBottleneck())
{
Queue.Enqueue(o);
}
}
finally
{
Mutex.ReleaseMutex();
}
}
/// <summary>
/// Removes the oldest item from the queue.
/// </summary>
/// <returns>The oldest item in the queue.</returns>
/// <exception cref="InvalidOperationException">If the queue is empty.</exception>
public static object Dequeue()
{
try
{
Mutex.WaitOne();
using (GlobalQueueBottleneck.EnterBottleneck())
{
return Queue.Dequeue();
}
}
finally
{
Mutex.ReleaseMutex();
}
}
}
/// <summary>
/// A class that access an EBS volume and reports on the associated bottleneck.
/// </summary>
class EbsAccessor
{
private const int IopsPageSize = 16 * 1024;
private readonly string _volumePrefix;
private readonly AmbientBottleneck _bottleneck = new("Ebs-Iops", AmbientBottleneckUtilizationAlgorithm.Linear, false, "A bottleneck which has a limit, but which is not based on access time.", 1000, TimeSpan.FromSeconds(1));
/// <summary>
/// Creates an EBS accessor for the specified volume.
/// </summary>
/// <param name="volumePrefix">The volume prefix, which will be prefixed onto <paramref name="ReadBytes.volumePrefix"/>"/> whenever a file is read from this volume.</param>
public EbsAccessor(string volumePrefix)
{
_volumePrefix = volumePrefix;
}
/// <summary>
/// Reads bytes from the specified location in the specified file.
/// </summary>
/// <param name="file">The file path (relative to the volume prefix specified in the constructor.</param>
/// <param name="fileOffset">The byte offset in the file where the read is to start.</param>
/// <param name="buffer">A buffer to put the data into.</param>
/// <param name="bufferOffset">The offset within the buffer where the read bytes should be placed.</param>
/// <param name="bytes">The number of bytes to attempt to read.</param>
/// <returns>The number of bytes that were actually read.</returns>
public int ReadBytes(string file, long fileOffset, byte[] buffer, int bufferOffset, int bytes)
{
string filePath = Path.Combine(_volumePrefix, file);
int bytesRead;
using (AmbientBottleneckAccessor? access = _bottleneck.EnterBottleneck())
{
using (FileStream f = new(filePath, FileMode.Open, FileAccess.Read))
{
f.Position = fileOffset;
bytesRead = f.Read(buffer, bufferOffset, bytes);
}
access?.SetUsage(1, (bytesRead + IopsPageSize - 1) / IopsPageSize); // note that this approximation of IOPS won't be correct if the file is fragmented, and the lookup and opening of the file will likely use some IOPS as well--more accurate estimates can be obtained after long-term usage and comparison to AWS metrics
}
return bytesRead;
}
}
/// <summary>
/// A class that collects bottleneck statistics and reports on them.
/// </summary>
class BottleneckReporter
{
private readonly AmbientBottleneckSurveyorCoordinator _surveyor = new();
private Dictionary<string, double>? _mostRecentWindowTopBottlenecks; // interlocked
private readonly IDisposable _timeWindow;
/// <summary>
/// Constructs a Bottleneck reporter that holds onto the top ten utilized bottlenecks for the entire process for the previous one-minute window.
/// </summary>
public BottleneckReporter()
{
_surveyor = new AmbientBottleneckSurveyorCoordinator();
_timeWindow = _surveyor.CreateTimeWindowSurveyor(TimeSpan.FromSeconds(60), OnMostRecentWindowClosed);
}
private Task OnMostRecentWindowClosed(IAmbientBottleneckSurvey analysis)
{
Dictionary<string, double> mostRecentWindowTopBottlenecks = new();
foreach (AmbientBottleneckAccessor record in analysis.GetMostUtilizedBottlenecks(10))
{
mostRecentWindowTopBottlenecks.Add(record.Bottleneck.Id, record.Utilization);
}
Interlocked.Exchange(ref _mostRecentWindowTopBottlenecks, mostRecentWindowTopBottlenecks);
return Task.CompletedTask;
}
/// <summary>
/// Gets a dictionary containing the top 10 bottlenecks with their overall utilization for the most recent time window.
/// </summary>
public Dictionary<string, double>? RecentBottleneckSummary => _mostRecentWindowTopBottlenecks;
}
Default Implementation
The default implementation uses thread-safe lock-free instances.
In order to use this effectively, users must strike a balance between conservative estimates of bottleneck saturation vs. having only inaccurate top bottlenecks in summaries.
DisposeResponsibility
The DisposeResponsibility generic class provides runtime detection of improper use of IDisposable instances.
The disposable pattern in C# has one glaring problem: there is often nothing in a function signature to indicate that the return value is disposable.
In addition, it's syntactically extremely clumsy to return tuples where one or more of the contained items are disposable.
Wrapping the output from the function in a DisposeResponsibility is not only a more visible indicator that the result needs disposal, but it will also notify callers if the instance is not disposed of properly.
DisposeResponsibility will automatically look inside tuples for disposables and dispose any or all of those items as needed.
For functions that return disposable instances where the caller is expected to dispose of the instance (as would usually be the case), switching to return a DisposeResponsibility instance will cause a notification if the instance is not disposed.
That notification can come in several forms. First, the DisposeResponsibility.ResponsibilityNotDisposed event can be subscribed to for immediate handling (for example Debugger.Break() during interactive debugging).
If there are no subscribers to that event, an AmbientLogger warning is written and the leak is recorded for DisposeResponsibility.AssertNoUndisposedDisposeResponsibilityLeaksAfterFullGc() (intended for test assembly cleanup after a full GC and finalizer drain).
The notification will include the stack trace indicating where the disposable instance was created (when creation-site collection is enabled, see below), so that the developer can determine why the instance wasn't disposed.
Care should be taken in the event handler for the DisposeResponsibility.ResponsibilityNotDisposed event to avoid throwing exceptions, as this could cause the application to crash because it is called by the garbage collector during finalization.
In most cases DisposeResponsibility instances returned from functions should be disposed of using a using block in the scope where the call is made, but sometimes the instances are returned higher up the stack, or stored inside another object for later disposal.
DisposeResponsibility ensures that responsibility for disposing of the instance is not lost.
The responsibility can be transferred to another caller or scope, but won't be lost unintentionally as often is the case without it.
Someday, it would be nice if C# incorporated this kind of explicit dispose responsibility tracking and transfer into the language, as it could greatly improve the readability and performance, and could eliminate all numerous false positive code analysis warnings.
Creation-site detail collection (off by default)
Capturing the stack that created every DisposeResponsibility is expensive, so it is off by default and controlled by the ambient setting whose key is DisposeResponsibility.CollectLeakDetailsSettingKey (DisposeResponsibility-CollectLeakDetails, a bool).
Turn it on in the projects and scenarios that actually report leaks — typically test assemblies and diagnostic runs — either process-wide through your settings set, or for one call context with using IDisposable leakDetails = DisposeResponsibility.ScopedLeakDetailCollection();.
Instances capture according to the value in effect where they are constructed, so the scope has to cover the construction, not just the reporting.
DisposeResponsibility.CollectLeakDetails reports the current value.
When collection is on, the creation stack is captured as a System.Diagnostics.StackTrace object and only rendered to a string if a leak is actually reported, so the happy path pays for a capture but never for a rendering.
When it is off, leaks are still reported (occurrence and contained type, plus the explicit creation-site string if one was passed) — just without a captured stack.
Hot paths can pass their own creation-site string to the constructor or AssumeResponsibility; that string is used verbatim, is unaffected by the setting, and suppresses the capture entirely.
DisposeResponsibility.AssertNoUndisposedDisposeResponsibilityLeaksAfterFullGc() throws InvalidOperationException when collection is off, naming the setting to enable, rather than "verifying" leaks that could never say where they came from.
So the assembly-cleanup verification below requires collection to be enabled where the instances are created and where the verification runs.
Historical note: the creation stack used to be rendered to a string eagerly at construction. That was required on .NET Framework, where finalizers ran during AppDomain unload and process shutdown and deferred resolution could touch already-finalized state. Modern .NET has no AppDomain unloads and never runs finalizers at shutdown, which is what makes deferring the rendering to report time safe. The one remaining way a deferred rendering can fail — a collectible AssemblyLoadContext unloading between capture and report — is caught, and degrades to a short placeholder instead of losing the leak report.
MSTest (VSTest / Visual Studio test explorer)
Finalization is non-deterministic, so a forgotten using may not surface until a GC runs. Test hosts (including Microsoft Testing Platform) are also poor places for Trace.Assert in finalizers. Instead, undisposed wrappers record a diagnostic when finalized (when there is no handler on DisposeResponsibility.ResponsibilityNotDisposed), and you assert during assembly cleanup after forcing collection and waiting for finalizers (DisposeResponsibility.AssertNoUndisposedDisposeResponsibilityLeaksAfterFullGc).
Add one [AssemblyCleanup] per test assembly, or merge that call into an existing cleanup method. Prefer running it after other teardown that must complete first (for example flushing trace or log buffers). This repository's main test assembly calls DisposeResponsibilityMstestVerification.AfterAllTestsInAssembly() from TestAmbientService.AssemblyCleanup after TraceBuffer.Flush.
Remember that the verification refuses to run unless creation-site collection is enabled (see above): enable it process-wide for the test assembly (for example from [AssemblyInitialize], through the settings set the tests use), or wrap the verification call in using IDisposable leakDetails = DisposeResponsibility.ScopedLeakDetailCollection(); — which is what this repository's AssemblyCleanup does. Enabling it process-wide is usually what you want, because instances constructed by the tests then carry their creation stacks into the report.
For interactive debugging, subscribe to DisposeResponsibility.ResponsibilityNotDisposed (for example from [AssemblyInitialize]) and use System.Diagnostics.Debugger.Break(); do not throw from that handler.
MSTest sample
The snippet below is generated from AmbientServices.Samples/Samples.cs (region DisposeResponsibilityMstestSample) when you build the samples project. It relies on using Microsoft.VisualStudio.TestTools.UnitTesting; (normally already present in MSTest projects). The same helper is exercised under dotnet test by AmbientServices.Test/Helpers/TestDisposeResponsibilityMstestReadme.cs.
/// <summary>
/// Optional MSTest integration for <see cref="DisposeResponsibility{T}"/>: run a full GC, wait for finalizers,
/// then fail the run if any wrapper was finalized without disposal (and without a <see cref="DisposeResponsibility.ResponsibilityNotDisposed"/> handler).
/// Call <see cref="AfterAllTestsInAssembly"/> once per test assembly, typically from a method marked <see cref="AssemblyCleanupAttribute"/>.
/// </summary>
public static class DisposeResponsibilityMstestVerification
{
/// <summary>
/// Runs <see cref="DisposeResponsibility.AssertNoUndisposedDisposeResponsibilityLeaksAfterFullGc"/>. Intended for <c>[AssemblyCleanup]</c>.
/// </summary>
public static void AfterAllTestsInAssembly() =>
DisposeResponsibility.AssertNoUndisposedDisposeResponsibilityLeaksAfterFullGc();
}
/// <summary>
/// Copy this pattern into your test assembly (one class is enough). Alternatively, call
/// <see cref="DisposeResponsibilityMstestVerification.AfterAllTestsInAssembly"/> from an existing <c>[AssemblyCleanup]</c> method after any other teardown you need (for example flushing logs).
/// The verification refuses to run unless creation-site collection is enabled, so this enables it (see <see cref="DisposeResponsibility.ScopedLeakDetailCollection"/>); enabling it process-wide from an <c>[AssemblyInitialize]</c> instead lets the instances your tests construct carry their creation stacks into the report.
/// </summary>
[TestClass]
public static class DisposeResponsibilityMstestAssemblyCleanupSample
{
[AssemblyCleanup]
public static void AssemblyCleanup()
{
// creation-site collection is off by default and the verification throws without it, so turn it on for this scope (which also covers the verification's own GC/finalizer drain)
using IDisposable leakDetails = DisposeResponsibility.ScopedLeakDetailCollection();
DisposeResponsibilityMstestVerification.AfterAllTestsInAssembly();
}
}
Sample
/// <summary>
/// A static class that contains utilities for managing files.
/// </summary>
public static class FileManager
{
/// <summary>
/// Opens two related files at the same time.
/// </summary>
/// <param name="filePath1">The full path to the first file to be opened.</param>
/// <param name="filePath2">The full path to the second file to be opened.</param>
public static DisposeResponsibility<(Stream Stream1, Stream Stream2)> OpenRelatedFiles(string filePath1, string filePath2)
{
#pragma warning disable CA2000 // Dispose objects before losing scope: this style warning is painfully wrong in this case
FileStream stream1 = new(filePath1, FileMode.Open, FileAccess.Read);
FileStream stream2;
try
{
stream2 = new(filePath2, FileMode.Open, FileAccess.Read);
#pragma warning restore CA2000 // Dispose objects before losing scope
}
catch
{
stream1.Dispose();
throw;
}
return new((stream1, stream2));
}
}
public class DisposeResponsibilityUsage
{
public static void Sample()
{
using DisposeResponsibility<(Stream Stream1, Stream Stream2)> files = FileManager.OpenRelatedFiles("file1.txt", "file2.txt");
#pragma warning disable CA2000 // Dispose objects before losing scope: this style warning is painfully wrong in this case
StreamReader reader1 = new(files.Contained.Stream1);
string s1 = reader1.ReadToEnd();
StreamReader reader2 = new(files.Contained.Stream2);
#pragma warning restore CA2000 // Dispose objects before losing scope
string s2 = reader1.ReadToEnd();
}
}
Status
The Status classes enable systems with automated background backend dependency status testing and aggregation of test results to generate concise status summary reports across backend systems and across server farms.
Some backend systems contain static status information which is gathered by a class that inherits from the abstract StatusChecker class, others need to be tested periodically.
The status for these systems is gathered by a class that inherits from the abstract StatusAuditor class.
StatusChecker and StatusAuditor classes with empty public constructors are automatically detected, constructed, and added to the global list of checkers.
Lazy construction and registration of status checkers is generally not a good idea, as detecting status issues as soon as possible during startup is preferable to detecting them only when the backend systems get initialized.
Even so, such classes with non-empty or non-public constructors can be registered manually if desired.
The status of each system is rated with a StatusRating floating-point number.
Systems are rated in one of four ranges, StatusRating.Fail, StatusRating.Alert, StatusRating.Okay, and StatusRating.Superlative.
Status rating numbers less than or equal to zero indicate that the corresponding system is in some degree of failure.
Status rating numbers greater than zero and less than or equal to one indicate that the corresponding system is not failing, but is in a state that needs attention such that a system administrator should be alerted.
Status rating numbers greater than one and less than or equal to two indicate that the corresponding system is working and no attention is needed.
Status rating numbers greater than two indicate that the corresponding system is "superlative", ie. better than just okay.
Systems that have not been tested yet are given a value of NaN.
Ratings may be much worse (less) than the threshold for Fail, and much better (higher) than the threshold for Okay, but the status system doesn't distinguish between values less than failure or greater than okay.
System status is indicated by a hierarchy of StatusResults objects, each containing the following properties:
- The source system (a string indicating the system that performed the audit).
- The target system (a slash-delimited string identifying the subsystem whose status is indicated).
- A
DateTimeindicating when the information was gathered. - A list of key-value properties.
- Either a list of child nodes, along with a
StatusNatureOfSystemindicating how the children are related to the parent and/or each other so that the system can automatically aggregate results, or - A
StatusAuditReportcontaining the following information about the audit:- A
DateTimeindicating when the audit started. - A
TimeSpanindicating how long the audit took. - An optional
DateTimeindicating when the next audit will happen. - An optional
StatusAuditAlertcontaining the following properties about an alert (if there is one):- A status rating number indicating the health of the system.
- An alert audit code (a short string that is constant across alerts of this type).
- A string containing a terse description of the problem, suitable for a plaintext SMS message.
- A string containing a detailed description of the problem, suitable for email, web, or mobile application display.
- A
A summary across backend systems, or a full rollup across an entire server farm may also be generated. Such a summary will collate results based primarily on the target system (because that is almost always the way problems are detected), secondarily on the alert code. If some source systems are reporting issues and others are not, the summary will indicate the sources reporting each status range. When the rating is due to properties falling outside configured thresholds, the reported property value ranges will also be indicated. The summarization system is designed to provide the relevant information for operations staff as concisely as possible in both SMS and detailed form. For example, an SMS status alert might look like the following:
🛑 FAIL @2:37 AM
AWS
RDS: [2]->Timeout
S3: [2]->Read Timeout
S3: [2]->Write Timeout
S3: [2]->Query Timeout
⚠️ ALERT
AWS
ES: [3]->Slow Response
Note that the timestamp at the top is to help the receiver know when message delivery was significantly delayed (this happens more than you might think, and can be very disconcerting when alerts come in hours after the actual incident). In this example, /AWS would be the target name of a node that contains children for each of the systems within AWS (RDS, S3, and ES in this case). The leading slash indicates that AWS is a top-level target, so targets specified in any parent nodes should be ignored. RDS would be the target name of the node (for each source) that contains the failure information about RDS. [2] indicates the number of sources reporting the same alert code. When only one source reports an issue, the full source is indicated.
The details will contain the corresponding detailed information, and a list of sources instead of just a count. The source is usually applied not by that system, but by the system gathering the results across the server farm. This is because when there are multiple levels of servers using the system, the system directly doing the testing may or may not be the source that system operators want to be reported.
The timing of audits is determined algorithmically, but will always be between one tenth and four times the baseline audit interval specified in the constructor (the constructor takes a TimeSpan, so the value is an interval even though the parameter is named for the frequency).
The time until the next audit is a function of that baseline interval, the rating, and the duration of the previous audit.
As status ratings degrade and audits speed up, the interval is reduced towards one tenth of the baseline interval.
This algorithm prevents slow audits from consuming too many resources, but also speeds up recovery detection when possible.
Settings
StatusChecker-HistoryRetentionMinutes: The maximum number of minutes to retain old StatusResults.
StatusChecker-HistoryRetentionEntries: The maximum number of old StatusResults entries to retain.
Sample
/// <summary>
/// A class that audits a specific drive.
/// </summary>
class DiskAuditor
{
private readonly DriveInfo _driveInfo;
private readonly string _testPath;
private readonly bool _readonly;
/// <summary>
/// Constructs a disk auditor.
/// </summary>
/// <param name="driveName">The name of the drive to be audited.</param>
/// <param name="testPath">A path within the drive to be used for testing.</param>
/// <param name="readOnly">Whether or not the test should be readonly. (The program may not have write access to some paths and still want to audit them for space usage and reading).</param>
public DiskAuditor(string driveName, string testPath, bool readOnly)
{
_driveInfo = new DriveInfo(driveName);
_testPath = testPath;
_readonly = readOnly;
}
/// <summary>
/// Performs the disk audit, reporting results into <paramref name="statusBuilder"/>.
/// </summary>
/// <param name="statusBuilder">A <see cref="StatusResultsBuilder"/> to write the results into.</param>
/// <param name="cancel">The optional <see cref="CancellationToken"/>.</param>
public async ValueTask Audit(StatusResultsBuilder statusBuilder, CancellationToken cancel = default)
{
statusBuilder.NatureOfSystem = StatusNatureOfSystem.Leaf;
statusBuilder.AddProperty("_Path", _driveInfo.Name);
statusBuilder.AddProperty("_VolumeLabel", _driveInfo.VolumeLabel);
statusBuilder.AddProperty("DriveFormat", _driveInfo.DriveFormat);
statusBuilder.AddProperty("DriveType", _driveInfo.DriveType);
statusBuilder.AddProperty("AvailableFreeBytes", _driveInfo.AvailableFreeSpace);
statusBuilder.AddProperty("TotalFreeBytes", _driveInfo.TotalFreeSpace);
statusBuilder.AddProperty("TotalBytes", _driveInfo.TotalSize);
if (!string.IsNullOrEmpty(_testPath))
{
if (_readonly)
{
StatusResultsBuilder readBuilder = new("Read");
statusBuilder.AddChild(readBuilder);
try
{
int attempt = 0;
// attempt to read a file (if one exists). note that under Linux, some files in the temp path may be inaccessible in such a way as to timeout attempting to open even as few as ten of them. this is probably a flaw in the .NET Core implementation on Linux.
foreach (string file in Directory.EnumerateFiles(Path.Combine(_driveInfo.RootDirectory.FullName, _testPath)))
{
AmbientStopwatch s = AmbientStopwatch.StartNew();
try
{
using (FileStream fs = new(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
byte[] b = new byte[1];
#pragma warning disable CA2022
await fs.ReadAsync(b.AsMemory(0, 1), cancel);
#pragma warning restore CA2022
await fs.FlushAsync(cancel);
}
readBuilder.AddProperty("ResponseMs", s.ElapsedMilliseconds);
readBuilder.AddOkay("Ok", "Success", "The read operation succeeded.");
break;
}
catch (IOException) // this will be thrown if the file cannot be accessed because it is open exclusively by another process (this happens a lot with temp files)
{
// only attempt to read up to 10 files
if (++attempt > 10) throw;
// just move on and try the next file
continue;
}
}
}
catch (Exception e)
{
readBuilder.AddException(e);
}
}
else
{
StatusResultsBuilder writeBuilder = new("Write");
StatusResultsBuilder readBuilder = new("Read");
statusBuilder.AddChild(writeBuilder);
statusBuilder.AddChild(readBuilder);
// attempt to write a temporary file
string targetPath = Path.Combine(_driveInfo.RootDirectory.FullName, Guid.NewGuid().ToString("N"));
try
{
AmbientStopwatch s = AmbientStopwatch.StartNew();
try
{
using FileStream fs = new(targetPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read, 4096);
byte[] b = new byte[1];
#pragma warning disable CA2022
await fs.WriteAsync(b.AsMemory(0, 1), cancel);
#pragma warning restore CA2022
await fs.FlushAsync(cancel);
writeBuilder.AddProperty("ResponseMs", s.ElapsedMilliseconds);
writeBuilder.AddOkay("Ok", "Success", "The write operation succeeded.");
}
catch (Exception e)
{
writeBuilder.AddException(e);
}
s.Reset();
try
{
using (FileStream fs = new(targetPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
byte[] b = new byte[1];
#pragma warning disable CA2022
await fs.ReadAsync(b.AsMemory(0, 1), cancel);
#pragma warning restore CA2022
await fs.FlushAsync(cancel);
}
readBuilder.AddProperty("ResponseMs", s.ElapsedMilliseconds);
readBuilder.AddOkay("Ok", "Success", "The read operation succeeded.");
}
catch (Exception e)
{
readBuilder.AddException(e);
}
}
finally
{
File.Delete(targetPath);
}
}
}
}
}
/// <summary>
/// An auditor for the local disk system. This class will be automatically instantiated when <see cref="Status.Start"/> is called and disposed when <see cref="Status.Stop"/> is called.
/// </summary>
public sealed class LocalDiskAuditor : StatusAuditor
{
private readonly bool _ready;
private readonly DiskAuditor _tempAuditor;
private readonly DiskAuditor _systemAuditor;
/// <summary>
/// Constructs the local disk auditor instance, which will audit the state of the local disk every 15 minutes (note that this frequency is to prevent the code from slowing down the unit tests; if this were used in the real world, one minute might be a better frequency).
/// </summary>
public LocalDiskAuditor() : base ("/LocalDisk", TimeSpan.FromMinutes(15))
{
string tempPath = Path.GetTempPath()!;
string tempDrive = Path.GetPathRoot(tempPath) ?? "/";
if (string.IsNullOrEmpty(tempPath) || string.IsNullOrEmpty(tempDrive)) tempDrive = tempPath = "/";
if (tempPath?[0] == '/') tempDrive = "/"; // on linux, the only "drive" is /
string tempPathRelative = tempPath!.Substring(tempDrive.Length);
_tempAuditor = new DiskAuditor(tempDrive, tempPathRelative, false); // note that under Linux, some files in the temp path may be inaccessible in such a way as to timeout attempting to open even as few as ten of them. this is probably a flaw in the .NET Core implementation on Linux.
string systemPath = Environment.GetFolderPath(Environment.SpecialFolder.System)!;
string systemDrive = Path.GetPathRoot(systemPath) ?? GetApplicationCodePath() ?? "/"; // use the application code path if we can't find the system root, if we can't get that either, try to use the root. on linux, we should get the application code path
if (string.IsNullOrEmpty(systemPath) || string.IsNullOrEmpty(systemDrive)) systemDrive = systemPath = "/";
if (systemPath?[0] == '/') systemDrive = "/";
string systemPathRelative = systemPath!.Substring(systemDrive.Length);
_systemAuditor = new DiskAuditor(systemDrive, systemPath, false);
_ready = true;
}
private static string GetApplicationCodePath()
{
AppDomain current = AppDomain.CurrentDomain;
return (current.RelativeSearchPath ?? current.BaseDirectory?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + Path.DirectorySeparatorChar;
}
protected override bool Applicable => _ready; // if S3 were optional (for example, if an alternative could be configured), this would check the configuration
public override async ValueTask Audit(StatusResultsBuilder statusBuilder, CancellationToken cancel = default)
{
statusBuilder.NatureOfSystem = StatusNatureOfSystem.ChildrenHeterogeneous;
await _tempAuditor.Audit(statusBuilder.AddChild("Temp"));
await _systemAuditor.Audit(statusBuilder.AddChild("System"));
}
}
class Application
{
/// <summary>
/// Starts the status system.
/// </summary>
public static async ValueTask StartStatus()
{
await Status.DefaultInstance.Start();
}
/// <summary>
/// Stops the status system.
/// </summary>
public static async ValueTask StopStatus()
{
await Status.DefaultInstance.Stop();
}
}
Miscellaneous
Several non-service type utilities and extensions to system classes are also included because they are needed by the implementations, but they are in separate namespaces AmbientServices.Utilities and AmbientServices.Extensions to avoid conflicts with existing code. These include InterlockedExtensions for threadsafe tracking of min/max values, ArrayExtensions for comparing arrays by value, StringExtensions for doing natural string comparisons, ConcurrentHashSet for keeping a keyed set of items in a thread-safe collection, Date (because that should be implemented by the system and isn't), International System of Units (SI) string generation for more readable status reports, FilteredStackTrace to remove system code tracing from exception stack dumps, and Pseudorandom for greatly improved and threadsafe random number generation.
Advanced Topics
AssemblyLoadContext
Many .NET projects use multiple assembly load contexts, and sometimes load assemblies at runtime. Care must be taken to ensure that the ambient services are correctly shared, or not shared, with dynamically-loaded assemblies.
A commonly misunderstood feature of AssemblyLoadContext is that by default it load separate copies of assemblies that have already been loaded in the main assembly load context, resulting in separate static/global variable values.
Automated regression tests for sharing vs isolating AmbientServices across a collectible load context live in TestAssemblyLoadContextAmbient (see AmbientServices.Test.AlcPlugin and AmbientServices.Test.AlcContract).
This causes AmbientService<T>.* variables to not flow into different assembly contexts.
If you want to share ambient services across assembly contexts, you need to ensure that the assembly loading in the separate AssemblyLoadingContext shares the AmbientServies assembly with the main loading context.
If you fail to do this you'll be perplexed when the ambient services all of a sudden revert to the defaults when you call across assembly load contexts or when you give some information to the ambient service inside the loaded assembly and then when you get back to the main program, it acts as if that never happened.
It will appear as if the ExecutionContext got corrupted, but in reality it's just that there are two separate ambient services.
This can be especially confusing when the two separate ambient services don't just implement the same interface, but are separate instances of the same singleton type.
The key being that even singletons are only singletons within their assembly load context.
Having separate ambient services in different assembly load contexts can be useful when the loaded assembly is not completely trusted by the caller, or doesn't share coding styles and conventions, so if you override the assembly loading to share assemblies, you also need to ensure that no ambient services are inappropriately leaked across these boundaries. In systems where some plugins are trusted and written by the same team, ambient services may need to flow across these boundaries, so the code may have to know which plugins to trust with some ambient services and which to not trust at all and use different assembly loading overrides to control the sharing appropriately.
When assemblies are partially-trusted, the controlling code may need to suppress some ambient services and flow others. This can be done by in either of two ways. The first way would be to not share AmbientServices assemblies in the assembly load context and to explicitly pass the needed ambient services to the partially-trusted assembly explicitly, at which point it can either use those explicit dependencies directly or store them in its separate ambient context. The second approach is to share ambient services by default, but selectively suppress specific ambient services when calling into the partially-trusted assembly like this:
private static AmbientService<IMyService> MySensitiveService;
private void CallIntoPartiallyTrustedCode(IPlugin plugin)
{
using IDisposable suppressSensitive = MySensitiveService.ScopedLocalOverride(null);
plugin.DoPluginStuff();
// at the end of the using, the ambient IMyService is restored to what it was on entry
}
Library Information
Author and License
AmbientServices is written and maintained by James Ivie.
AmbientServices is licensed under MIT.
Language and Tools
AmbientServices is written in C#, using .NET Standard 2.0, .NET 8.0-10.0. Unit tests are written in .NET 10.0. The code can be built using either Microsoft Visual Studio 2026+, Microsoft Visual Studio Code, or .NET Core command-line utilities.
Binaries are available at https://www.nuget.org/packages/AmbientServices.
Contributions
Contributions are welcome under the following conditions:
- enhancements are consistent with the overall scope of the project
- no new assembly dependencies are introduced
- code coverage by unit tests cover all new lines and conditions whenever possible
- documentation (both inline and here) is updated appropriately
- style for code and documentation contributions remains consistent
| Product | Versions 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 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. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 is compatible. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- System.Collections.Immutable (>= 10.0.12)
- System.Text.Json (>= 10.0.12)
-
.NETStandard 2.1
- System.Collections.Immutable (>= 10.0.12)
- System.Text.Json (>= 10.0.12)
-
net10.0
- No dependencies.
-
net8.0
- System.Collections.Immutable (>= 10.0.12)
- System.Text.Json (>= 10.0.12)
-
net9.0
- System.Collections.Immutable (>= 10.0.12)
- System.Text.Json (>= 10.0.12)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on AmbientServices:
| Package | Downloads |
|---|---|
|
AmbientServices.Async
An MIT-licensed library containing utilities to migrate to async and improve multithreaded async performance. |
|
|
AmbientTypes
An MIT-licensed library that provides consistent type factory interfaces for any type including immutable types, as well as a random data generator that uses those factories. Generated data is intended for dynamically-generated sample code as well as for testing purposes. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.9.332 | 201 | 9/19/2026 |
| 0.9.331 | 392 | 9/13/2026 |
| 0.9.330 | 265 | 9/8/2026 |
| 0.9.329 | 303 | 9/7/2026 |
| 0.9.328 | 1,118 | 8/17/2026 |
| 0.9.327 | 114 | 8/15/2026 |
| 0.9.326 | 105 | 8/15/2026 |
| 0.9.325 | 110 | 8/14/2026 |
| 0.9.324 | 100 | 8/13/2026 |
| 0.9.323 | 461 | 8/5/2026 |
| 0.9.322 | 113 | 8/3/2026 |
| 0.9.321 | 338 | 8/3/2026 |
| 0.9.320 | 503 | 7/20/2026 |
| 0.9.319 | 119 | 7/20/2026 |
| 0.9.318 | 116 | 7/19/2026 |
| 0.9.317 | 182 | 7/15/2026 |
| 0.9.316 | 400 | 7/13/2026 |
| 0.9.315 | 268 | 7/3/2026 |
| 0.9.314 | 119 | 7/3/2026 |
| 0.9.313 | 118 | 7/3/2026 |