Skip to main content

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.