Partitioned shields
A stateful shield normally shares one circuit, rate bucket, or concurrency limit wherever that
shield instance is reused. PartitionedShield<TKey> keeps an independent shield per endpoint,
tenant, shard, authority, or operation while placing an explicit bound on retained state.
var endpoints = new PartitionedShield<string>(
endpoint => Shield.When<TimeoutExceededException>()
.CircuitBreaker(consecutiveFailures: 5, breakDuration: TimeSpan.FromSeconds(30)),
new PartitionedShieldOptions<string>
{
MaxPartitions = 500,
IdleExpiration = TimeSpan.FromMinutes(20),
},
StringComparer.OrdinalIgnoreCase);
var shield = endpoints.GetShield("inventory.internal");
Calls with the same key receive the same immutable shield and share its strategy state. Different
keys receive different shields. Concurrent first lookup runs the partition factory exactly once.
For factories that perform asynchronous initialization, use CreateAsync and GetShieldAsync so
same-key callers await the shared creation instead of blocking thread-pool threads:
var asyncPartitions = PartitionedShield<string>.CreateAsync(async key =>
{
await Task.Yield();
return Shield.Retry(2, Backoff.Exponential(TimeSpan.FromMilliseconds(20)));
});
var asyncShield = await asyncPartitions.GetShieldAsync("inventory.internal");
The typed PartitionedShield<TKey, TResult> variant returns Shield<TResult> and supports
result-aware handling and fallbacks.
Retention and eviction
Every provider is bounded. MaxPartitions defaults to 1,000, and the least recently used
partition is evicted before that limit can be exceeded. IdleExpiration is optional and
opportunistic: expired entries are removed by later provider operations or an explicit
PruneExpired() call; no timer or background worker is retained.
After OnEvicted completes and any execution already using the shield finishes, Kevlar releases
the evicted shield's disposable strategies in reverse pipeline order. A strategy instance shared
by multiple partition providers is disposed only after its last owner releases it. The provider
owns every strategy returned by its factory by default. Set OwnsStrategies = false when the
factory returns strategy instances also used by standalone or registry shields; the external owner
must then dispose them. Do not retain and reuse a shield after its partition is evicted. A later
lookup of the evicted key creates a new shield with fresh breaker, limiter, and queue state. Core
strategies hold no long-lived disposable resources; this lifecycle primarily matters for custom
strategies and adapters such as an owned RateLimiter.
When an owned partition contains disposable strategies, each shield execution allocates one small
async-flow lifetime token. The token must be unique so provider disposal can distinguish the exact
protected execution from detached tasks and concurrent later executions. Set
OwnsStrategies = false to avoid provider lifetime tracking when another component owns and
disposes those strategies.
Lifecycle callbacks report both the key and shield and return ValueTask; return default for
synchronous work. Callback failures are swallowed so telemetry or cleanup cannot fail a lookup. The
owned strategies of a newly published shield remain alive until OnCreated and the creating lookup
complete, even if another operation evicts that partition concurrently. The eviction callback is
awaited before a capacity slot is reused. If that callback performs a cold lookup while its caller owns all available capacity, the
nested lookup receives an unretained shield instead of waiting on its own reservation; a later
lookup creates and retains the partition normally. Its owned strategies are released when the
callback finishes. Explicit TryRemove and Clear removals use the Cleared reason; idle expiry
uses Expiration. Disposing a provider from its own OnCreated or OnEvicted callback is rejected
because the provider must wait for that callback before disposal can complete.
var observed = new PartitionedShield<string>(
static _ => Shield.Empty,
new PartitionedShieldOptions<string>
{
MaxPartitions = 2,
OnCreated = item =>
{
Console.WriteLine($"Created {item.Key}");
return default;
},
OnEvicted = async item =>
{
await Task.Yield();
Console.WriteLine($"Evicted {item.Key}: {item.Reason}");
},
});
Count, CreatedCount, EvictionCount, and the reason-specific eviction counters expose
lifecycle status. TryGetShield, TryRemove, Clear, and their async cleanup variants provide
explicit cache control. If the factory throws, no existing partition is evicted and the failed key
is not cached.
PartitionedShield implements both IDisposable and IAsyncDisposable. Disposing it rejects new
operations, clears live partitions, waits for factories, removal callbacks, and active executions
already in flight, and disposes owned strategies exactly once. Concurrent disposal callers await
the same operation and observe the same failure. Synchronous removal and disposal APIs prefer
IDisposable; asynchronous APIs prefer IAsyncDisposable when a strategy implements both. Keyed
partition providers registered through DI are singleton-owned and are therefore disposed with the
service provider.
Dependency injection
AddPartitionedShield registers a named provider as a keyed singleton. The factory receives the
application service provider and the partition key.
using Kevlar.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
services.AddPartitionedShield<Uri>(
"endpoints",
(serviceProvider, endpoint) => Shield.When<HttpRequestException>()
.CircuitBreaker(consecutiveFailures: 5, breakDuration: TimeSpan.FromSeconds(30))
.WithName("outbound-endpoint"),
options =>
{
options.MaxPartitions = 200;
options.IdleExpiration = TimeSpan.FromMinutes(15);
});
public sealed class EndpointClient(
[FromKeyedServices("endpoints")] PartitionedShield<Uri> shields)
{
public Task<HttpResponseMessage> SendAsync(Uri endpoint, CancellationToken cancellationToken) =>
shields.GetShield(endpoint)
.ExecuteAsync(token => SendCoreAsync(endpoint, token), cancellationToken)
.AsTask();
private static Task<HttpResponseMessage> SendCoreAsync(
Uri endpoint,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
Typed providers use two generic arguments. This multi-tenant example isolates fallback and breaker state per tenant while sharing one bounded provider:
using Kevlar.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
services.AddPartitionedShield<string, TenantResult>(
"tenants",
(serviceProvider, tenantId) => Shield.For<TenantResult>()
.When<TimeoutExceededException>()
.CircuitBreaker(consecutiveFailures: 3, breakDuration: TimeSpan.FromSeconds(20))
.WithName("tenant-operation"),
options => options.MaxPartitions = 5_000,
StringComparer.Ordinal);
Metrics cardinality
kevlar.partitions.evictions counts removals with the bounded kevlar.partition.reason tag. The
reason is capacity, idle, or cleared; the partition key is deliberately omitted.
Partition keys are not copied into Shield.Name, KevlarContext, or metric tags. Give partitions
one shared low-cardinality name when aggregate telemetry is useful. State gauges aggregate matching
name/index series: concurrency and rate values are summed, while
kevlar.circuit_breaker.instances reports a count for each circuit state. If a controlled key dimension
is required, add it in application-owned instrumentation after applying your own cardinality bound;
do not use unrestricted tenant IDs or URLs as metric labels.
Partitioning is outside the core execution path. Non-partitioned shields are unchanged, and a warm
GetShield lookup uses the existing dictionary entry without per-call closure allocation.