-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe Configuration Reload
A long-running worker that picks up changed configuration — a feature flag, a batch size, a downstream URL — without redeploying or restarting the process. Plumber reads configuration once, at Build(), so the way to "reload" is to rebuild a fresh handler from the recipe and swap it in. A fresh Build() re-reads config from disk, so the new handler sees the new values; the old one is disposed.
Plumber does not watch files for changes. That's deliberate — an in-place reload would refresh IConfiguration but leave the already-built pipeline, service provider, and bound options stale (a split-brain), and host-free workloads usually ship config as a new deployment anyway. This recipe is for the case where you genuinely need live reload in a process that stays up: you own the change trigger, and you own the swap.
- A long-running process — a queue consumer, a polling worker, a daemon — that must outlive a config change.
- Config arrives in a way that changes under a running process: a Kubernetes
ConfigMap/Secretmounted as a volume (the kubelet updates the file in place), an operator editing a file, aSIGHUP, an admin endpoint. - You can identify a quiescent point in your own loop where no request is in flight — between queue messages, between poll cycles. That's where the swap happens.
If your process is request-per-invocation and short-lived (a Lambda, a CLI), you don't need this — the next invocation already reads fresh config.
A console worker that processes items in a loop and greets each one using a Greeting value from appsettings.json. It wires a FileSystemWatcher to the config file; when the file changes, the worker rebuilds its pipeline at the top of the next loop iteration and swaps it in. Edit appsettings.json while it runs and the greeting changes — no restart.
dotnet new console -n ConfigReload
cd ConfigReload
dotnet add package MSL.Plumber.PipelineAn appsettings.json, copied to the output directory:
{
"Greeting": "Hello"
}<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewer</CopyToOutputDirectory>
</None>
</ItemGroup>The request is the name to greet; the response is the greeting. One middleware reads Greeting from configuration at request time and formats the response. Configuration is resolved from the per-request scope, so it's whatever the current handler was built with:
using Microsoft.Extensions.Configuration;
using Plumber;
namespace ConfigReload;
internal sealed class GreetingMiddleware(RequestMiddleware<string, string> next)
{
public Task InvokeAsync(RequestContext<string, string> context, IConfiguration configuration)
{
var greeting = configuration["Greeting"] ?? "Hello";
context.Response = $"{greeting}, {context.Request}!";
return next(context);
}
}The recipe — CreateBuilder + Configure + Build — is the unit we rebuild. Each call to Build() reads appsettings.json fresh:
using Plumber;
namespace ConfigReload;
internal static class Pipeline
{
public static RequestHandlerBuilder<string, string> CreateBuilder(string[] args) =>
RequestHandlerBuilder.Create<string, string>(args)
.AddJsonFile("appsettings.json", optional: true);
public static RequestHandler<string, string> Configure(RequestHandler<string, string> handler) =>
handler.Use<GreetingMiddleware>();
public static RequestHandler<string, string> Build(string[] args) =>
Configure(CreateBuilder(args).Build());
}The whole pattern is "build a new handler, atomically replace the reference, dispose the old one." Wrapping it keeps the swap correct in one place. Interlocked.Exchange makes the reference swap atomic, so a reader between requests always sees a fully-built handler — never a half-swapped one:
using Plumber;
namespace ConfigReload;
// Owns the current handler and rebuilds it on demand. The swap is atomic; disposal of the
// previous handler is the caller's quiescence guarantee (see Reload).
internal sealed class ReloadableHandler(Func<RequestHandler<string, string>> build) : IDisposable
{
private RequestHandler<string, string> current = build();
public Task<string?> InvokeAsync(string request, CancellationToken cancellationToken) =>
Volatile.Read(ref current).InvokeAsync(request, cancellationToken);
// Build a fresh handler (re-reads config) and swap it in. Call this at a quiescent point —
// no request in flight against the current handler — because the old handler is disposed
// immediately. For a one-at-a-time loop, "between iterations" is quiescent by construction.
public void Reload()
{
var next = build();
var previous = Interlocked.Exchange(ref current, next);
previous.Dispose();
}
public void Dispose() => current.Dispose();
}The caveat is the one piece you can't skip: do not Reload() while a request is in flight against the current handler. The wrapper disposes the old handler the instant it swaps, so an overlapping InvokeAsync on the old instance would throw ObjectDisposedException. A sequential loop reloads between iterations, which is naturally quiescent. If your worker processes requests concurrently, you own the draining — quiesce in-flight work before calling Reload(), or carry a reference count and dispose the old handler only after it drains. Plumber doesn't do this for you; you know your concurrency and it doesn't.
The owner supplies the trigger. Here it's a FileSystemWatcher on appsettings.json; the callback sets a flag, and the loop acts on it at the top of each iteration — its quiescent point. Other triggers fit the same shape: PosixSignalRegistration for SIGHUP, an admin endpoint that sets the flag, a periodic re-read.
using ConfigReload;
var reloadRequested = 0;
using var watcher = new FileSystemWatcher(AppContext.BaseDirectory, "appsettings.json")
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,
EnableRaisingEvents = true,
};
watcher.Changed += (_, _) => Interlocked.Exchange(ref reloadRequested, 1);
watcher.Created += (_, _) => Interlocked.Exchange(ref reloadRequested, 1);
using var handler = new ReloadableHandler(() => Pipeline.Build(args));
foreach (var name in new[] { "Ada", "Linus", "Grace" })
{
if (Interlocked.Exchange(ref reloadRequested, 0) == 1)
{
handler.Reload(); // top of the loop — quiescent, no request in flight
Console.WriteLine("configuration reloaded");
}
var greeting = await handler.InvokeAsync(name, CancellationToken.None);
Console.WriteLine(greeting);
await Task.Delay(TimeSpan.FromSeconds(2)); // window to edit appsettings.json
}Run it, and while it's looping edit appsettings.json — change "Hello" to "Hola". The next iteration logs configuration reloaded and the greeting switches, with no restart.
A middleware that reads configuration["Greeting"] per request does see file edits if the config root reloads — but only the values it reads directly at request time. Anything captured at build time — options bound in ConfigureServices, a constructor-injected singleton, the middleware list itself — stays frozen until you rebuild. Rebuilding the whole handler is the only way to refresh all of it consistently, which is why this recipe rebuilds rather than relying on live reads.
Test the pipeline directly with PlumberApplicationFactory: seed the configuration with WithInMemorySettings, invoke, and assert on the greeting — no file or watcher needed.
using Plumber.Testing;
[Fact]
public async Task GreetsWithConfiguredGreetingAsync()
{
using var factory = new PlumberApplicationFactory<string, string>(
Pipeline.CreateBuilder,
Pipeline.Configure)
.WithInMemorySettings([new("Greeting", "Hola")]);
var greeting = await factory.InvokeAsync("Ada", TestContext.Current.CancellationToken);
Assert.Equal("Hola, Ada!", greeting);
}The reload itself — build, swap, dispose — is just calling the recipe again; there's nothing Plumber-specific to test beyond the pipeline. Assert your ReloadableHandler swaps and disposes if you want coverage on the wrapper.
- .NET 10
-
MSL.Plumber.Pipeline3.x
- Building a pipeline → Reloading configuration — the reference note this recipe expands.
-
Building a pipeline → Multiple Build() calls — each
Build()is independent, which is what makes rebuild-and-swap safe. - BackgroundService worker — a generic-host worker loop you can fold this swap into.
Documents Plumber v4.x · Repository · MIT License · Report an issue
Getting Started
Pipeline (core)
Testing
Serilog Extensions
Diagnostics
Recipes
- AWS Lambda — API Gateway
- AWS Lambda — SQS
- Azure Functions — HTTP
- SQS polling console
- ASP.NET Core integration
- BackgroundService worker
- Webhook receiver
- Multi-command CLI
- File watcher
- Configuration reload
Repo · NuGet · NuGet — Testing · NuGet — Serilog · NuGet — Diagnostics