MultiInstanceWorker 3.0.0
dotnet add package MultiInstanceWorker --version 3.0.0
NuGet\Install-Package MultiInstanceWorker -Version 3.0.0
<PackageReference Include="MultiInstanceWorker" Version="3.0.0" />
<PackageVersion Include="MultiInstanceWorker" Version="3.0.0" />
<PackageReference Include="MultiInstanceWorker" />
paket add MultiInstanceWorker --version 3.0.0
#r "nuget: MultiInstanceWorker, 3.0.0"
#:package MultiInstanceWorker@3.0.0
#addin nuget:?package=MultiInstanceWorker&version=3.0.0
#tool nuget:?package=MultiInstanceWorker&version=3.0.0
MultiInstanceWorker
Provider-agnostic building blocks for running background workers across multiple instances of an application: leader election for singleton workloads, instance discovery, and even splitting of a set of named workloads across live instances.
This library defines the coordination logic and the storage contracts it needs. It does not ship a Redis, SQL, or any other backing-store implementation — a consuming application supplies that by implementing three small interfaces.
Pieces
IInstanceIdentityProvider/ProcessInstanceIdentityProvider— a unique id for the current process (machine:pid:guid), used to prove lease/heartbeat ownership.IInstanceRegistry— tracks which instances are alive, and whether they're draining. A consumer implements this against its own store (heartbeat write with TTL, list active non-draining instances - each anActiveInstancewith itsInstanceIdand a write-onceJoinedAtUtc- mark draining, remove on shutdown).ILeaseManager— grants ownership of a single named workload to exactly one instance at a time. A consumer implements this against its own store, and must guarantee mutual exclusion on renewal (e.g. a short arbitration lock around a read-then-write of the lease record).IWorkloadStatusStore— tracks each workload's lifecycle state (WorkloadStatus:Active/Transferring/Inactive) across the fleet, each write TTL-bounded.LeasedWorkerRunnerwrites it automatically as a workload moves through its lifecycle; a coordinator reads it before starting a newly assigned workload to avoid starting a runner for one still finishing up elsewhere.GetAllAsync()reads every currently live record fleet-wide - handy for a diagnostics endpoint.LeasedWorkerRunner/LeasedWorkerHostedService— drives a workload's lifecycle: acquire-or-renew the lease everyrenewInterval, start the workload when owned, stop it when the lease is lost, and release it when told to stop. Whether it's told to stop because a coordinator reassigned it elsewhere or because the whole host is shutting down makes no difference to the runner - either way it stops taking new work but lets an in-flight worker finish on its own, up to adrainTimeoutceiling, instead of cancelling it immediately. It never marks the whole instance draining itself - see "Draining an instance" below.LeasedWorkerRunneris the internal state machine;LeasedWorkerHostedService(the public entry point) adapts it to the hosted-service lifecycle.IWorkloadAssigner— decides which of a set of named workloads belong to this instance, given the active instances, so a coordinator knows what to attempt locally. It's a liveness/efficiency decision, not the safety one —ILeaseManagerstill enforces exclusive ownership underneath whatever it picks. Two implementations ship today:BalancedNamedWorkloadAssigner— deterministically slices the workloads evenly across the active instance ids (sorted, even split, remainder to the earliest instances).PrimaryNodeWorkloadAssigner— active/passive instead of spread out: every workload goes to whichever active instance joined earliest (ActiveInstance.JoinedAtUtc, not id sort order - a later-joining instance can never sort ahead of an already-running incumbent just because its id happens to sort earlier). Failover falls out for free: once the primary's heartbeat expires and it drops out of the active set, whichever survivor joined earliest picks up everything on the next reconcile tick.
WorkloadCoordinatorHostedService<TWorkload>— the sharded-workload counterpart toLeasedWorkerHostedService: one hosted service that heartbeats, re-evaluates itsIWorkloadAssigner's assignment every tick, and reconciles oneLeasedWorkerRunnerper assigned workload — starting runners for newly assigned workloads and stopping ones that dropped out of the assignment, whether reassigned elsewhere or the instance itself is draining - same graceful treatment either way. Workload construction and execution stay entirely in theexecuteAsyncdelegate you supply — this class knows nothing about what a workload actually does.IDrainableService— an optionalRequestDrain()contract a workload can implement to receive its own cooperative stop signal, instead of depending onIInstanceRegistryjust to pollIsDraining. Pass the workload asdrainabletoLeasedWorkerHostedService(or resolve it per-workload viaWorkloadCoordinatorHostedService<TWorkload>'sdrainableSelector) andRequestDrain()is called the moment that runner enters drain mode — well beforedrainTimeoutwould force a cancellation. It's still optional: a workload can instead observeIInstanceRegistry.IsDrainingitself, or just rely on the cancellation token plusdrainTimeout.LeaderElectionConfig— timing knobs (lease TTL / renew interval, heartbeat TTL / interval, drain timeout) with validation that renewal intervals stay safely inside their TTLs.AddLeasedWorker/AddWorkloadCoordinator<TWorkload>—IServiceCollectionextensions that register the two hosted services above without hand-writing their constructor wiring (see Usage sketch below). Both defaultIInstanceIdentityProvidertoProcessInstanceIdentityProvider(andAddWorkloadCoordinatordefaultsIWorkloadAssignertoBalancedNamedWorkloadAssigner) viaTryAdd, so a consumer's own registration always wins if they need something else.ILeaseManager,IInstanceRegistry, andIWorkloadStatusStoreare never defaulted — register those against your own store before calling either extension.
Draining an instance
Marking a whole instance draining (IInstanceRegistry.BeginDrainAsync) is always a
deliberate, external act — nothing in this library calls it on your behalf, no matter
why a workload's runner stops. Wire it up as your own endpoint or lifecycle hook (a
Kubernetes preStop hook hitting it is the common case). Once called,
GetActiveInstancesAsync excludes the instance fleet-wide, so no new workload gets
assigned to it, while every workload already running there keeps finishing on its own
(up to drainTimeout). A bare shutdown with no prior drain call still winds every
workload down gracefully — it just won't proactively exclude the instance from new
assignments until its heartbeat naturally expires.
Usage sketch
services.AddSingleton<ILeaseManager, YourLeaseManager>(); // your store adapter
services.AddSingleton<IInstanceRegistry, YourInstanceRegistry>(); // your store adapter
services.AddSingleton<IWorkloadStatusStore, YourWorkloadStatusStore>(); // your store adapter
services.AddLeasedWorker(
workloadKey: "singleton:some-background-job",
displayName: "Some background job",
executeAsync: (sp, ct) => sp.GetRequiredService<SomeBackgroundJob>().ExecuteAsync(ct),
configFactory: sp => sp.GetRequiredService<IOptions<YourWorkerTimingOptions>>().Value,
drainableSelector: sp => sp.GetRequiredService<SomeBackgroundJob>());
Have SomeBackgroundJob implement IDrainableService and check its own flag at safe boundaries
(e.g. between units of work), rather than relying solely on drainTimeout — that ceiling exists to
force a stop if the worker doesn't cooperate, not as the primary drain signal. This keeps the
workload itself free of any dependency on IInstanceRegistry; it only needs to know "wrap up now".
For a set of sharded (not just singleton) workloads, use AddWorkloadCoordinator<TWorkload>
instead of one AddLeasedWorker call per workload:
services.AddWorkloadCoordinator(
YourWorkloadCatalog.All,
keySelector: w => w.Key,
displayNameSelector: w => w.DisplayName,
executeAsync: (sp, workload, ct) => sp.GetRequiredService<YourWorkloadRunner>().ExecuteAsync(workload, ct),
configFactory: sp => sp.GetRequiredService<IOptions<YourWorkerTimingOptions>>().Value,
drainableSelector: (sp, workload) => sp.GetRequiredService<YourWorkloadRunner>().GetDrainable(workload));
// workloadAssigner: new PrimaryNodeWorkloadAssigner(), // opt into active/passive instead of balanced
YourWorkload is entirely your own type — the coordinator only needs a key and a display name out
of it. configFactory receives the IServiceProvider (rather than a plain value) specifically so
it can come from the options pattern - IOptions<T> isn't resolvable until the container is built,
which is after these Add* calls run.
Constructing LeasedWorkerHostedService/WorkloadCoordinatorHostedService<TWorkload> directly
(as AddLeasedWorker/AddWorkloadCoordinator<TWorkload> do internally) still works if you need
finer control than the extensions give you.
Why no MultiInstanceWorker.Redis package (yet)
This package stays free of any specific backing-store dependency (no StackExchange.Redis, no SQL
driver) - you supply ILeaseManager, IInstanceRegistry, and IWorkloadStatusStore against
whatever store you already use. The project repository's sample app includes a real, tested Redis
implementation of all three you can use as a starting point.
More
Full docs, a runnable Redis-backed sample, and the project's test suite live in the source repository: https://github.com/pavlek1817/multiinstance-worker.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.11)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.11)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.