Skip to content

Bulkhead

Granit.Bulkhead provides per-tenant bulkhead isolation built on .NET 10’s System.Threading.RateLimiting.ConcurrencyLimiter. When a tenant has too many concurrent operations in flight, excess requests are rejected immediately (HTTP 503) rather than queued — preventing thread pool exhaustion without hiding latency problems.

[DependsOn(typeof(GranitBulkheadModule))]
public class AppModule : GranitModule { }
{
"Bulkhead": {
"Enabled": true,
"BypassRoles": ["SystemAdmin"],
"Policies": {
"api": { "PermitLimit": 20, "QueueLimit": 5, "QueueTimeout": "00:00:30" },
"import": { "PermitLimit": 2, "QueueLimit": 0 },
"report-generation": { "PermitLimit": 3, "QueueLimit": 0 }
}
}
}
app.MapGet("/reports", handler)
.RequireGranitBulkhead("report-generation");
app.MapPost("/import", handler)
.RequireGranitBulkhead("import");

RequireGranitBulkhead is an endpoint filter. It acquires a lease before the handler runs and releases it in a finally block — the lease is always released, even on unhandled exceptions.

When the bulkhead is full, BulkheadRejectedException is thrown and mapped to 503 Service Unavailable by Granit.ExceptionHandling.

For background jobs and message handlers, use the [Bulkhead] attribute and register the middleware once:

// 1. Decorate the message
[Bulkhead("import")]
public record ImportDataCommand(Guid TenantId, Stream Data);
// 2. Register middleware in Wolverine setup
opts.Policies.AddMiddleware<BulkheadMiddleware>(
chain => chain.MessageType
.GetCustomAttributes(typeof(BulkheadAttribute), true).Length > 0);

The middleware follows Wolverine’s before/after convention — the lease is released after the handler completes, whether it succeeds or throws.

PropertyDefaultDescription
EnabledtrueMaster switch — disabling returns NoOp leases for all policies
BypassRoles[]Roles that skip bulkhead checks. Machine actors always bypass regardless
UseFeatureBasedQuotasfalseResolve PermitLimit dynamically from Granit.Features
IdleTimeout00:30:00Evict unused per-tenant limiters after this idle period
CleanupInterval00:05:00How often the background cleanup job runs
PoliciesrequiredNamed policy definitions (case-insensitive keys)
PropertyDefaultRangeDescription
PermitLimit101–10,000Max concurrent operations per tenant for this policy
QueueLimit00–10,000Max queued requests when slots are full. 0 = reject immediately
QueueTimeout00:00:30positiveMax wait in queue. Only applies when QueueLimit > 0
FeatureNamenullNumeric Granit.Features feature to override PermitLimit per plan

Requests skip the bulkhead check when:

  1. Machine actorsICurrentUserService.IsMachine = true (always bypassed)
  2. Configured roles — user is in a role listed in BypassRoles
  3. DisabledEnabled = false
  4. Unknown policy — policy name not found in Policies (no-op, no error)

When UseFeatureBasedQuotas = true, the PermitLimit for each policy is resolved dynamically from a Granit.Features Numeric feature before each acquisition:

{
"Bulkhead": {
"UseFeatureBasedQuotas": true,
"Policies": {
"import": {
"PermitLimit": 2,
"FeatureName": "Bulkhead.ImportLimit"
}
}
}
}

If the feature is not defined or IFeatureChecker is not registered, it falls back to the static PermitLimit from configuration.

Granit.Bulkhead emits OpenTelemetry metrics via the Granit.Bulkhead meter:

MetricTypeDescription
granit.bulkhead.leases.activeUpDownCounterCurrently active leases
granit.bulkhead.requests.rejectedCounterRequests rejected (bulkhead full)

Both metrics carry policy and tenant_id attributes.

flowchart TD
    REQ([Incoming request]) --> DISABLED{Bulkhead\ndisabled?}
    DISABLED -- Yes --> NOOP[NoOp lease]
    DISABLED -- No --> POLICY{Policy\nfound?}
    POLICY -- No --> NOOP
    POLICY -- Yes --> BYPASS{Machine actor\nor bypass role?}
    BYPASS -- Yes --> NOOP
    BYPASS -- No --> QUOTA[Resolve PermitLimit\nstatic or feature-based]
    QUOTA --> ACQUIRE[ConcurrencyLimiter\nAcquireAsync]
    ACQUIRE -- Acquired --> LEASE[BulkheadLease\nIsAcquired = true]
    ACQUIRE -- Full\nno queue --> REJECT[BulkheadRejectedException\nHTTP 503]
    ACQUIRE -- Queue full\nor timeout --> REJECT
    LEASE --> HANDLER[Handler executes]
    HANDLER --> DISPOSE[Lease.Dispose\nrelease permit]
TypePackageDescription
GranitBulkheadModuleGranit.BulkheadModule class
TenantPartitionedBulkheadGranit.BulkheadCore orchestrator — inject to acquire leases manually
BulkheadLeaseGranit.BulkheadDisposable permit holder
IBulkheadQuotaProviderGranit.BulkheadOverride to provide dynamic limits
BulkheadRejectedExceptionGranit.BulkheadThrown when bulkhead is full; mapped to HTTP 503
BulkheadAttributeGranit.BulkheadWolverine message marker
RequireGranitBulkhead()Granit.BulkheadEndpoint filter extension
BulkheadMiddlewareGranit.BulkheadWolverine before/after middleware
AddGranitBulkhead()Granit.BulkheadDI registration extension
GranitBulkheadOptionsGranit.BulkheadTop-level options
BulkheadPolicyOptionsGranit.BulkheadPer-policy options