Consistent Random
Introduction
This Ruby gem allows you to generate consistent random values tied to a specific name within a defined scope. It ensures that random behavior remains consistent within a particular context.
Consistent Random is designed to simplify feature rollouts and other scenarios where you need to generate random values, but need those values to remain consistent within defined contexts.
For example, consider rolling out a new feature to a subset of requests. You may want to do this to allow testing a new feature by only enabling it for 10% of requests. You want to randomize which requests get the new feature, but ensure that within each request, the feature is consistently enabled or disabled across all actions. This gem allows you to achieve that by tying random values to specific names and defining a scope. Within that scope, the same value will be consistently generated for each named variable.
Table of Contents
- Usage
- Middlewares
- Rack Middleware
- Sidekiq Middleware
- ActiveJob
- Testing
- Installation
- Contributing
- License
Usage
To generate consistent random values, you need to define a scope. Scopes are defined with the ConsistentRandom.scope
method. Within the scope block, calls to ConsistentRandom
will return the same random values for the same name. Scopes are isolated to the block in which they're defined, meaning random values are consistent within each scoped block but independent across threads or separate invocations.
ConsistentRandom.scope do
random = ConsistentRandom.new("foobar")
a = random.rand(100) # Generates a random number between 0 and 99 tied to "foobar"
b = random.rand(100) # Same random number as 'a', because "foobar" is reused
a == b # => true
end
This can be used to implement things like feature flags for rolling out new features on a percentage of your requests.
class FeatureFlag
def initialize(name, roll_out_percentage)
@name = name
@roll_out_percentage = roll_out_percentage
end
def enabled?
ConsistentRandom.new("FeatureFlag.#{@name}").rand < @roll_out_percentage
end
end
Checking a feature flag will return the same value within a scope.
class MyService
def call(arg)
if FeatureFlag.new("new_feature", 0.1).enabled?
# Do something new 10% of the time
else
# Do something else
end
end
end
ConsistentRandom.scope do
things.each do |thing|
MyService.new.call(thing) # You won't get a mix of new and old behavior within this iteration
end
end
If there is no scope defined, the random values will be different each time for different instances of ConsistentRandom
. So, if your code is executed outside of a scope, it will still work but with random values being generated rather than consistent values.
random = ConsistentRandom.new("foobar")
random.rand != random.rand # => true
Middlewares
The gem provides built-in middlewares for Rack, Sidekiq, and ActiveJob. These middlewares allow you to automatically scope web requests and propagate consistent random values from the original request to asynchronous jobs.
Rack Middleware
In a Rack application:
Rack::Builder.app do
use ConsistentRandom::RackMiddleware
run MyApp
end
Or in a Rails application:
# config/application.rb
config.middleware.use ConsistentRandom::RackMiddleware
You can also specify a seed value based on the request. This can be useful if you want to generate random values based on a specific request attribute, such as the current user.
Rack::Builder.app do
use ConsistentRandom::RackMiddleware, ->(env) { env["warden"].user.id }
run MyApp
end
If the seed block returns nil
, then a random seed will be generated for the request.
Sidekiq Middleware
Add the middlewares to your Sidekiq in an initializer:
ConsistentRandom::SidekiqMiddleware.install
This will install both the client and server middleware. You can also install them manually if you need more control on the order of the middlewares. You should install the client middleware on both the server and client configurations.
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.prepend ConsistentRandom::SidekiqMiddleware
end
config.client_middleware do |chain|
chain.add ConsistentRandom::SidekiqClientMiddleware
end
end
Sidekiq.configure_client do |config|
config.client_middleware do |chain|
chain.add ConsistentRandom::SidekiqClientMiddleware
end
end
Consistent random values will be propagated from the original request to any Sidekiq jobs so you will get consistent behavior on any ansynchronous jobs. You can disable this behavior on a job by setting the conistent_random
sidekiq option to false
:
class MyWorker
include Sidekiq::Job
sidekiq_options consistent_random: false
def perform
# Each job will use it's own random scope.
end
end
You can still specify a custom seed value in your worker if, for example, you want to ensure that values are consistent based on a user when the job is not enqueued from a Rack request.
class MyWorker
include Sidekiq::Job
def perform(user_id)
ConsistentRandom.scope(user_id) do
...
end
end
end
ActiveJob
You can use consistent random values in your ActiveJob jobs by including the ConsistentRandom::ActiveJob
module.
class MyJob < ApplicationJob
include ConsistentRandom::ActiveJob
def perform
# Job will use consistent random values using the same scope from when it was enqueued.
end
end
Jobs will inherit the same consistent random values as the request that spawned the job. You can force a job to use it's own random scope by setting the consistent_random
option to false
:
class MyJob < ApplicationJob
include ConsistentRandom::ActiveJob
self.inherit_consistent_random_scope = false
def perform
# Job will use it's own random scope.
end
end
You can still specify a custom seed value in your worker if, for example, you want to ensure that values are consistent based on a user when the job is not enqueued from a Rack request.
class MyJob < ApplicationJob
def perform(user_id)
ConsistentRandom.scope(user_id) do
...
end
end
end
Testing
The gem provides a ConsistentRandom.testing
method to allow for deterministic testing of random values. This method can be used to set fixed values within the block so that your tests will produce consistent results.
# Specify that all random values should be 0.5
ConsistentRandom.testing.rand(0.5) do
expect(ConsistentRandom.new("foo").rand).to eq(0.5)
expect(ConsistentRandom.new("bar").rand).to eq(0.5)
# The rand value must be between 0 and 1, but it will be scaled to fit
# any size or range specified for `rand`.
expect(ConsistentRandom.new("foo").rand(10)).to eq(5)
end
# You can also specify values for specific names.
# If a values isn't specified, it will return a random value.
ConsistentRandom.testing(foo: 0.5, bar: 0.8) do
expect(ConsistentRandom.new("foo").rand).to eq(0.5)
expect(ConsistentRandom.new("bar").rand).to eq(0.8)
end
# You can also specify values for the `bytes` and `seed` methods. The methods
# for setting test valus can be chained together.
ConsistentRandom.testing.bytes(foo: "bar").seed(baz: 123) do
expect(ConsistentRandom.new("foo").bytes(6)).to eq("barbar")
expect(ConsistentRandom.new("baz").seed).to eq(123)
end
Installation
Add this line to your application's Gemfile:
gem "consistent_random"
Then execute:
$ bundle
Or install it yourself as:
$ gem install consistent_random
Contributing
Open a pull request on GitHub.
Please use the standardrb syntax and lint your code with standardrb --fix
before submitting.
License
The gem is available as open source under the terms of the MIT License.