Decode.Hosting
3.0.0
dotnet add package Decode.Hosting --version 3.0.0
NuGet\Install-Package Decode.Hosting -Version 3.0.0
<PackageReference Include="Decode.Hosting" Version="3.0.0" />
<PackageVersion Include="Decode.Hosting" Version="3.0.0" />
<PackageReference Include="Decode.Hosting" />
paket add Decode.Hosting --version 3.0.0
#r "nuget: Decode.Hosting, 3.0.0"
#:package Decode.Hosting@3.0.0
#addin nuget:?package=Decode.Hosting&version=3.0.0
#tool nuget:?package=Decode.Hosting&version=3.0.0
Decode.Hosting
Resilient periodic background workers for .NET, each cycle running inside its own dependency injection scope and its own trace span.
🚀 Features
- Scope per cycle: every iteration gets a fresh
IServiceScope, so scoped services — aDbSession, a unit of work, anHttpClientwith scoped handlers — behave exactly as they do in a web request instead of being captured for the lifetime of the process. - Survives failures: an unhandled exception in a cycle is logged, backed off, and the worker continues. Opt out with
SuppressExceptions = false. - Does not block startup: a
RunOnStartupcycle runs after the host has finished starting, not during it. - Instrumented: one span per cycle on the
Decode.Hostingactivity source, tagged with the worker name and execution id. - Clear failure on misconfiguration: a non-positive
Intervalfails with a message naming the property, instead of throwing from insideTask.Delay.
📦 Installation
dotnet add package Decode.Hosting
🛠️ Setup
1. Write the worker
Derive from ScopedPeriodicWorker and override ExecuteScopedAsync. The constructor must accept
IServiceScopeFactory, ILogger and WorkerOptions to pass to the base class — the registration
supplies all three. Any additional constructor parameters are resolved from the container.
using Decode.Hosting;
using Decode.Hosting.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
public class ExpireTokensWorker(
IServiceScopeFactory scopeFactory,
ILogger<ExpireTokensWorker> logger,
WorkerOptions options)
: ScopedPeriodicWorker(scopeFactory, logger, options)
{
protected override async Task ExecuteScopedAsync(
IServiceProvider serviceProvider,
WorkerContext context)
{
// Resolve from the cycle's scope, never from a captured root provider.
ITokenRepository repository = serviceProvider.GetRequiredService<ITokenRepository>();
int removed = await repository.DeleteExpiredAsync(context.CancellationToken);
logger.LogInformation(
"Cycle {ExecutionId} removed {Count} expired tokens.",
context.ExecutionId,
removed);
}
}
Resolve scoped dependencies from the serviceProvider argument, not from the constructor. The worker
itself is registered as a singleton, so a scoped service taken through its constructor either throws
InvalidOperationException — "Cannot resolve scoped service from root provider", which is what scope
validation reports, on by default in Development — or, with validation off, silently resolves once and
stays captured for the lifetime of the process. IServiceScopeFactory exists to avoid both.
2. Register it
using Decode.Hosting;
builder.Services.AddDecodeBackgroundWorker<ExpireTokensWorker>(options =>
{
options.Interval = TimeSpan.FromMinutes(15);
options.RunOnStartup = true;
options.ErrorBackoffDelay = TimeSpan.FromSeconds(30);
options.SuppressExceptions = true;
});
The
using Decode.Hosting;matters.AddDecodeBackgroundWorkerlives in theDecode.Hostingnamespace rather thanMicrosoft.Extensions.DependencyInjection, so it will not appear onIServiceCollectionwithout it.
Called without the delegate, the worker runs with the WorkerOptions
defaults: every minute, starting immediately,
surviving errors with a 5 second backoff.
⏱️ Timing
| Situation | Time until the next cycle starts |
|---|---|
| Normal cycle | Interval + how long the cycle took |
Failed cycle, SuppressExceptions = true |
ErrorBackoffDelay + Interval |
| Host shutting down | no next cycle; the loop exits |
The delay is between cycles, so cycle duration is additive — this is a periodic worker, not a fixed-rate scheduler. If you need a cycle to start at an exact wall-clock time, this is the wrong tool; use a scheduler such as Quartz or Hangfire.
RunOnStartup runs its cycle after the host has started. BackgroundService executes ExecuteAsync
synchronously up to its first incomplete await and IHost.StartAsync awaits that, so without care a
first cycle taking a minute would delay the whole application by a minute. The worker yields before
doing anything for exactly this reason.
🔥 Failure behaviour
options.SuppressExceptions = true; // default
An unhandled exception is logged at Error with the execution id, the cycle's span is marked
Error with the exception message, the worker waits ErrorBackoffDelay and carries on. This is the
right default for a worker whose job will probably succeed next time — a transient database timeout,
a rate-limited API.
options.SuppressExceptions = false;
The exception is logged at Critical and rethrown. That faults the BackgroundService, and since
.NET 6 the default BackgroundServiceExceptionBehavior is StopHost — the application shuts down.
Choose this when a failing cycle means the process is no longer trustworthy and you would rather have
the orchestrator restart it.
Two things are never treated as failures: an OperationCanceledException raised because the host is
shutting down is logged at Information, and a cycle interrupted by shutdown does not trigger the
error backoff.
A non-positive Interval throws InvalidOperationException naming WorkerOptions.Interval. Because
the check runs on the worker's background task, it surfaces either from StartAsync or from the
faulted execute task depending on scheduling — either way the host does not keep running with a
worker that would spin.
📊 Telemetry
Each cycle opens a span named {WorkerTypeName}.Execute on the activity source Decode.Hosting:
| Tag | Value |
|---|---|
worker.name |
the worker's type name |
worker.execution_id |
the cycle's WorkerContext.ExecutionId |
The span status is set to Error with the exception message when a cycle throws, whether or not the
failure is suppressed.
Nothing is exported until the source is registered. With Decode.Telemetry:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddDecodeInstrumentation() // includes Decode.Hosting
.AddOtlpExporter());
Or directly, without taking the dependency:
tracing.AddSource("Decode.Hosting");
This package emits no metrics.
⚠️ One worker type, one registration
AddDecodeBackgroundWorker<TWorker> registers through AddHostedService<TWorker>, which de-duplicates
by service and implementation type. Calling it twice for the same TWorker keeps the first
registration and silently discards the second:
// Does not do what it looks like — the second call is ignored.
builder.Services.AddDecodeBackgroundWorker<SyncWorker>(o => o.Interval = TimeSpan.FromMinutes(5));
builder.Services.AddDecodeBackgroundWorker<SyncWorker>(o => o.Interval = TimeSpan.FromHours(1));
To run the same logic on two schedules, declare two types — a shared base class with two thin subclasses is enough.
📖 Configuration from appsettings.json
AddDecodeBackgroundWorker takes options in code. To bind them from configuration, register the
worker yourself and use the IOptions<WorkerOptions> constructor that ScopedPeriodicWorker also
provides:
builder.Services.Configure<WorkerOptions>(builder.Configuration.GetSection("ExpireTokensWorker"));
builder.Services.AddHostedService<ExpireTokensWorker>();
public class ExpireTokensWorker(
IServiceScopeFactory scopeFactory,
ILogger<ExpireTokensWorker> logger,
IOptions<WorkerOptions> options)
: ScopedPeriodicWorker(scopeFactory, logger, options)
{
// ...
}
Note that WorkerOptions is a single type, so one Configure<WorkerOptions> call covers every worker
bound this way. Use named options if two workers need different values from configuration.
📄 License
MIT License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Decode.Hosting.Abstractions (>= 3.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options (>= 8.0.2)
-
net8.0
- Decode.Hosting.Abstractions (>= 3.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options (>= 8.0.2)
-
net9.0
- Decode.Hosting.Abstractions (>= 3.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options (>= 8.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.