Percentage-Based Rollout
The Rollout package provides deterministic, percentage-based traffic allocation for gradual feature rollouts. This enables safe deployment of new implementations by controlling what percentage of users receive the new variant.
Installation
dotnet add package ExperimentFramework.Rollout
Quick Start
// 1. Register rollout support
services.AddExperimentRollout();
// 2. Provide an identity provider for consistent assignment
services.AddScoped<IRolloutIdentityProvider, UserIdProvider>();
// 3. Configure your experiment
var experiments = ExperimentFrameworkBuilder.Create()
.Define<IPaymentProcessor>(exp => exp
.UsingRollout(percentage: 25) // 25% get new implementation
.AddControl<LegacyPaymentProcessor>("legacy")
.AddCondition<NewPaymentProcessor>("new-v2"));
services.AddExperimentFramework(experiments);
How It Works
Deterministic Assignment
The rollout allocator uses a hash-based algorithm that ensures:
- Consistency: The same user always gets the same variant
- Determinism: Results are reproducible across application restarts
- Uniform distribution: Traffic is evenly distributed according to the percentage
// The algorithm combines identity + experiment name for assignment
public static bool IsIncluded(string identity, string selectorName, int percentage, string? seed = null)
{
var input = $"{seed ?? selectorName}:{identity}";
var hash = ComputeHash(input);
var bucket = (int)(hash % 100);
return bucket < percentage;
}
Identity Provider
You must implement IRolloutIdentityProvider to supply the identity used for bucketing:
public interface IRolloutIdentityProvider
{
ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default);
}
// Example implementation
public class UserIdProvider : IRolloutIdentityProvider
{
private readonly IHttpContextAccessor _httpContext;
public UserIdProvider(IHttpContextAccessor httpContext)
{
_httpContext = httpContext;
}
public ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default)
{
var userId = _httpContext.HttpContext?.User.FindFirst("sub")?.Value;
return ValueTask.FromResult(userId);
}
}
Configuration Options
Basic Rollout
services.AddExperimentRollout(options =>
{
options.Percentage = 50; // 50% rollout
options.Seed = "my-experiment"; // Optional: override bucket seed
options.IncludedKey = "new"; // Key for included users
options.ExcludedKey = "legacy"; // Key for excluded users
});
Fluent Configuration
.Define<ISearchService>(exp => exp
.UsingRollout(percentage: 10, includedKey: "elastic", excludedKey: "legacy")
.AddControl<SqlSearchService>("legacy")
.AddCondition<ElasticSearchService>("elastic"))
Staged Rollout
For gradual rollouts over time, use staged rollout with scheduled percentage increases:
services.AddExperimentStagedRollout(options =>
{
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow,
Percentage = 5
});
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow.AddDays(1),
Percentage = 25
});
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow.AddDays(3),
Percentage = 50
});
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow.AddDays(7),
Percentage = 100
});
});
Staged Rollout Fluent API
.Define<INotificationService>(exp => exp
.UsingStagedRollout(stages: new[]
{
(DateTimeOffset.UtcNow, 10),
(DateTimeOffset.UtcNow.AddDays(1), 50),
(DateTimeOffset.UtcNow.AddDays(7), 100)
})
.AddControl<EmailNotificationService>("email")
.AddCondition<PushNotificationService>("push"))
Configuration File Support
Enable YAML/JSON configuration for rollouts:
services.AddExperimentRolloutConfiguration();
services.AddExperimentRollout();
services.AddExperimentFrameworkFromConfiguration(configuration);
YAML Configuration
experimentFramework:
trials:
- serviceType: IPaymentProcessor
selectionMode:
type: rollout
percentage: 50
seed: payment-v2-experiment
includedKey: new-processor
excludedKey: legacy-processor
variants:
- key: legacy-processor
implementationType: LegacyPaymentProcessor
isControl: true
- key: new-processor
implementationType: NewPaymentProcessor
Staged Rollout Configuration
experimentFramework:
trials:
- serviceType: ISearchService
selectionMode:
type: stagedRollout
stages:
- startsAt: "2024-01-01T00:00:00Z"
percentage: 10
- startsAt: "2024-01-08T00:00:00Z"
percentage: 50
- startsAt: "2024-01-15T00:00:00Z"
percentage: 100
variants:
- key: legacy
implementationType: SqlSearchService
isControl: true
- key: elastic
implementationType: ElasticSearchService
Real-World Examples
Database Migration
Gradually migrate from one database to another:
public class DatabaseMigrationIdentityProvider : IRolloutIdentityProvider
{
private readonly IHttpContextAccessor _httpContext;
public ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default)
{
// Use tenant ID for B2B scenarios
var tenantId = _httpContext.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
return ValueTask.FromResult(tenantId);
}
}
// Configure staged migration
services.AddExperimentStagedRollout(opts =>
{
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow, Percentage = 1 });
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow.AddDays(7), Percentage = 10 });
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow.AddDays(14), Percentage = 50 });
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow.AddMonths(1), Percentage = 100 });
});
var experiments = ExperimentFrameworkBuilder.Create()
.Define<IOrderRepository>(exp => exp
.UsingStagedRollout()
.AddControl<SqlOrderRepository>("sql")
.AddCondition<CosmosOrderRepository>("cosmos"));
API Version Migration
.Define<IExternalApiClient>(exp => exp
.UsingRollout(percentage: 20)
.AddControl<ApiClientV1>("v1")
.AddCondition<ApiClientV2>("v2"))
Best Practices
- Start small: Begin with 1-5% rollout to catch issues early
- Use meaningful seeds: Seed names help with debugging and reproducibility
- Monitor closely: Combine with telemetry to track variant performance
- Plan rollback: Have a strategy to quickly reduce percentage if issues arise
- Consider identity: Choose identity carefully - user ID, session ID, or tenant ID based on your use case
Troubleshooting
Users switching between variants
Symptom: Users report inconsistent behavior, seeing different variants across requests.
Cause: Identity provider returning different values for the same user.
Solution: Ensure your identity provider returns a stable, consistent identifier:
// Bad: Session-based identity changes across sessions
return httpContext.Session.Id;
// Good: User ID is consistent
return httpContext.User.FindFirst("sub")?.Value;
Rollout percentage not matching expected
Symptom: Observing ~45% rollout when configured for 50%.
Cause: Hash distribution has natural variance, especially with small sample sizes.
Solution: This is expected statistical behavior. With larger user bases (10,000+), distribution approaches the configured percentage.
No identity available
Symptom: All traffic goes to control variant.
Cause: Identity provider returning null.
Solution: Handle anonymous users explicitly:
public ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default)
{
var userId = _httpContext.HttpContext?.User.FindFirst("sub")?.Value;
// Fall back to a session or cookie-based identity for anonymous users
if (string.IsNullOrEmpty(userId))
{
userId = _httpContext.HttpContext?.Request.Cookies["visitor_id"];
}
return ValueTask.FromResult(userId);
}