consistent_random 1.0.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22c5eb562537bed9919f77d7d5daeafcd33c8279e91c902d66cfc48b07183a19
4
- data.tar.gz: 27000167f87f8b8a02723d8e160d054f59a4d077bf5e22c5834a978e99f6f492
3
+ metadata.gz: 53c979ca8b32310fc3357e231ed29a029964396e19f1bafd0ac44220be535a1a
4
+ data.tar.gz: 35e7431d888b473db4063a8cec225314aae0e445740388a97ee9296d5a559c03
5
5
  SHA512:
6
- metadata.gz: 6c2d988676f767b138d64e44c6f87f97e08058a5c2fd0a7ce528b4728f624d8c887753182b409c9b343ee3ece9d93f681306fcbecbf7967c5e9c524eb4c95b19
7
- data.tar.gz: 7ff4c55d561b0947d7d51d7c96b11e9bf85dcccdc0e11c7f1b6c25923cc3b23696dcdd8ee3069c46c358a81506ab46cc3980a7fc3e3f12929d86c97c7ed19c50
6
+ metadata.gz: cbc32f5cbbf0ad48742e9ede2e14a187530a32b2e832a0b9af5b293c9716f3e9893795601853ccc4b32ad9cbb24b70decb88f9b94f2c17b7ba687bfca2cd1810
7
+ data.tar.gz: 80ebaa9bfd1ba8519e755369cf76184e89c97c88302979a7b4fe0d176fbb107a3734a9fa2aa820ad80d0f5f3b370f7755b721080be7ec79219df48a0e6c5d3a9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.1.0
8
+
9
+ ### Added
10
+
11
+ - Added optional seed block to the Rack middleware to allow for custom seed values based on the request.
12
+ - Added helper method `ConsistentRandom::SidekiqMiddleware.install` to install both the client and server middlewares in one call.
13
+
14
+ ## 2.0.0
15
+
16
+ ### Changed
17
+
18
+ - `ConsistentRandom#rand` uses a faster hashing algorithm for generating random values. This will make the value consistent across different Ruby versions and platforms.
19
+
20
+ ### Added
21
+
22
+ - Added `ConsistentRandom::SidekiqClientMiddleware` to allow persisting consistent random seeds to Sidekiq jobs so behavior is consistent between when jobs are enqueued and when they are executed.
23
+ - Added `ConsistentRandom::ActiveJob` for hooking into ActiveJob to persist consistent random seeds to jobs.
24
+
7
25
  ## 1.0.0
8
26
 
9
27
  ### Added
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Constant Random
1
+ # Consistent Random
2
2
 
3
3
  [![Continuous Integration](https://github.com/bdurand/consistent_random/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/consistent_random/actions/workflows/continuous_integration.yml)
4
4
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
@@ -6,13 +6,25 @@
6
6
 
7
7
  ## Introduction
8
8
 
9
- 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, such as handling feature rollouts.
9
+ 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.
10
10
 
11
- For example, consider rolling out a new feature to a subset of requests, such as enabling the feature 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.
11
+ 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.
12
+
13
+ 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.
14
+
15
+ ## Table of Contents
16
+ - [Usage](#usage)
17
+ - [Middlewares](#middlewares)
18
+ - [Rack Middleware](#rack-middleware)
19
+ - [Sidekiq Middleware](#sidekiq-middleware)
20
+ - [ActiveJob](#activejob)
21
+ - [Installation](#installation)
22
+ - [Contributing](#contributing)
23
+ - [License](#license)
12
24
 
13
25
  ## Usage
14
26
 
15
- To generate consistent random values, you need to define a scope. You do this with the `ConsistentRandom.scope` method. Within the scope block, calls to `ConsistentRandom` will return the same random values for the same name.
27
+ 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.
16
28
 
17
29
  ```ruby
18
30
  ConsistentRandom.scope do
@@ -65,11 +77,11 @@ random = ConsistentRandom.new("foobar")
65
77
  random.rand != random.rand # => true
66
78
  ```
67
79
 
68
- ### Middlewares
80
+ ## Middlewares
69
81
 
70
- The gem provides built-in middlewares for Rack and Sidekiq, automatically scoping requests and jobs. This ensures that consistent random values are generated within the request/job context.
82
+ 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.
71
83
 
72
- #### Rack Middleware
84
+ ### Rack Middleware
73
85
 
74
86
  In a Rack application:
75
87
 
@@ -87,14 +99,109 @@ Or in a Rails application:
87
99
  config.middleware.use ConsistentRandom::RackMiddleware
88
100
  ```
89
101
 
90
- #### Sidekiq Middleware
102
+ 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.
103
+
104
+ ```ruby
105
+ Rack::Builder.app do
106
+ use ConsistentRandom::RackMiddleware, ->(env) { env["warden"].user.id }
107
+ run MyApp
108
+ end
109
+ ```
91
110
 
92
- Add the middleware to your Sidekiq server configuration:
111
+ If the seed block returns `nil`, then a random seed will be generated for the request.
112
+
113
+ ### Sidekiq Middleware
114
+
115
+ Add the middlewares to your Sidekiq in an initializer:
116
+
117
+ ```ruby
118
+ ConsistentRandom::SidekiqMiddleware.install
119
+ ```
120
+
121
+ 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.
93
122
 
94
123
  ```ruby
95
124
  Sidekiq.configure_server do |config|
96
125
  config.server_middleware do |chain|
97
- chain.add ConsistentRandom::SidekiqMiddleware
126
+ chain.prepend ConsistentRandom::SidekiqMiddleware
127
+ end
128
+
129
+ config.client_middleware do |chain|
130
+ chain.add ConsistentRandom::SidekiqClientMiddleware
131
+ end
132
+ end
133
+
134
+ Sidekiq.configure_client do |config|
135
+ config.client_middleware do |chain|
136
+ chain.add ConsistentRandom::SidekiqClientMiddleware
137
+ end
138
+ end
139
+ ```
140
+
141
+ 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`:
142
+
143
+ ```ruby
144
+ class MyWorker
145
+ include Sidekiq::Job
146
+
147
+ sidekiq_options consistent_random: false
148
+
149
+ def perform
150
+ # Each job will use it's own random scope.
151
+ end
152
+ end
153
+ ```
154
+
155
+ 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.
156
+
157
+ ```ruby
158
+ class MyWorker
159
+ include Sidekiq::Job
160
+
161
+ def perform(user_id)
162
+ ConsistentRandom.scope(user_id) do
163
+ ...
164
+ end
165
+ end
166
+ end
167
+ ```
168
+
169
+ ### ActiveJob
170
+
171
+ You can use consistent random values in your ActiveJob jobs by including the `ConsistentRandom::ActiveJob` module.
172
+
173
+ ```ruby
174
+ class MyJob < ApplicationJob
175
+ include ConsistentRandom::ActiveJob
176
+
177
+ def perform
178
+ # Job will use consistent random values using the same scope from when it was enqueued.
179
+ end
180
+ end
181
+ ```
182
+
183
+ 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`:
184
+
185
+ ```ruby
186
+ class MyJob < ApplicationJob
187
+ include ConsistentRandom::ActiveJob
188
+
189
+ self.inherit_consistent_random_scope = false
190
+
191
+ def perform
192
+ # Job will use it's own random scope.
193
+ end
194
+ end
195
+ ```
196
+
197
+ 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.
198
+
199
+ ```ruby
200
+ class MyJob < ApplicationJob
201
+ def perform(user_id)
202
+ ConsistentRandom.scope(user_id) do
203
+ ...
204
+ end
98
205
  end
99
206
  end
100
207
  ```
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 2.1.0
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConsistentRandom
4
+ module ActiveJob
5
+ def self.included(base)
6
+ attr_reader :consistent_random_seeds
7
+
8
+ base.around_perform :peform_with_consistent_random_scope
9
+
10
+ base.class_attribute :inherit_consistent_random_scope, instance_writer: false
11
+ end
12
+
13
+ def serialize
14
+ job_data = super
15
+ if inherit_consistent_random_scope != false
16
+ seed = ConsistentRandom.current_seed
17
+ job_data["consistent_random_seed"] = seed unless seed.nil?
18
+ end
19
+ job_data
20
+ end
21
+
22
+ def deserialize(job_data)
23
+ super
24
+ @consistent_random_seeds = job_data["consistent_random_seed"]
25
+ end
26
+
27
+ private
28
+
29
+ def peform_with_consistent_random_scope(&block)
30
+ seeds = consistent_random_seeds unless inherit_consistent_random_scope == false
31
+ ConsistentRandom.scope(seeds, &block)
32
+ end
33
+ end
34
+ end
@@ -4,12 +4,19 @@ class ConsistentRandom
4
4
  # Rack middleware that wraps a request with consistent random scope
5
5
  # so that you can generate consistent random values within a request.
6
6
  class RackMiddleware
7
- def initialize(app)
7
+ # @param app [Object] Rack application to wrap
8
+ # @param seed_block [Proc, #call, nil] block to generate seed for the request
9
+ # If provided, the block will be called with the request env and
10
+ # the return value will be used as the seed for the request. You can
11
+ # use this to generate a seed based on the request state..
12
+ def initialize(app, seed_block = nil)
8
13
  @app = app
14
+ @seed_block = seed_block
9
15
  end
10
16
 
11
17
  def call(env)
12
- ConsistentRandom.scope do
18
+ seed = @seed_block.call(env) if @seed_block
19
+ ConsistentRandom.scope(seed) do
13
20
  @app.call(env)
14
21
  end
15
22
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConsistentRandom
4
+ # Sidekiq client middleware that adds the current seeds to the job options. These
5
+ # seeds will be deserialized and used when the job is run on the server so that
6
+ # the client and server can share consistent random values.
7
+ class SidekiqClientMiddleware
8
+ if defined?(Sidekiq::ClientMiddleware)
9
+ include Sidekiq::ClientMiddleware
10
+ end
11
+
12
+ def call(job_class_or_string, job, queue, redis_pool)
13
+ unless job["consistent_random"] == false
14
+ seed = ConsistentRandom.current_seed
15
+ job["consistent_random_seed"] = seed unless seed.nil?
16
+ end
17
+ yield
18
+ end
19
+ end
20
+ end
@@ -8,8 +8,28 @@ class ConsistentRandom
8
8
  include Sidekiq::ServerMiddleware
9
9
  end
10
10
 
11
+ class << self
12
+ def install
13
+ Sidekiq.configure_server do |config|
14
+ config.server_middleware do |chain|
15
+ chain.prepend ConsistentRandom::SidekiqMiddleware
16
+ end
17
+
18
+ config.client_middleware do |chain|
19
+ chain.add ConsistentRandom::SidekiqClientMiddleware
20
+ end
21
+ end
22
+
23
+ Sidekiq.configure_client do |config|
24
+ config.client_middleware do |chain|
25
+ chain.add ConsistentRandom::SidekiqClientMiddleware
26
+ end
27
+ end
28
+ end
29
+ end
30
+
11
31
  def call(job_instance, job_payload, queue)
12
- ConsistentRandom.scope do
32
+ ConsistentRandom.scope(job_payload["consistent_random_seed"]) do
13
33
  yield
14
34
  end
15
35
  end
@@ -1,25 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "consistent_random/context"
4
- require_relative "consistent_random/rack_middleware"
5
- require_relative "consistent_random/sidekiq_middleware"
3
+ require "digest/sha1"
4
+ require "securerandom"
6
5
 
7
6
  class ConsistentRandom
7
+ SEED_DIVISOR = (2**64 - 1).to_f
8
+ private_constant :SEED_DIVISOR
9
+
10
+ autoload :RackMiddleware, "consistent_random/rack_middleware"
11
+ autoload :SidekiqMiddleware, "consistent_random/sidekiq_middleware"
12
+ autoload :SidekiqClientMiddleware, "consistent_random/sidekiq_client_middleware"
13
+ autoload :ActiveJob, "consistent_random/active_job"
14
+
8
15
  class << self
9
16
  # Define a scope where consistent random values will be generated.
10
17
  #
18
+ # @param seeds [String, Symbol, Integer, Array<String>, Array<Integer>, nil] optional value to
19
+ # use for generating random numbers. By default a random value will be generated. If the
20
+ # scope is nested in another scope block, then the seed from the parent scope will be used
21
+ # by default.
11
22
  # @yield block of code to execute within the scope
12
23
  # @return the result of the block
13
- def scope
14
- existing_context = Thread.current[:consistent_random_context]
24
+ def scope(seed = nil)
25
+ existing_seed = Thread.current[:consistent_random_seed]
26
+ seed_value = case seed
27
+ when nil
28
+ existing_seed || SecureRandom.hex
29
+ when String, Symbol, Integer
30
+ seed.to_s
31
+ when Array
32
+ seed.map { |s| s.to_s }.join("\x1C")
33
+ else
34
+ raise ArgumentError, "Invalid seed value: #{seed.inspect}"
35
+ end
36
+
15
37
  begin
16
- context = Context.new(existing_context)
17
- Thread.current[:consistent_random_context] = context
38
+ Thread.current[:consistent_random_seed] = seed_value
18
39
  yield
19
40
  ensure
20
- Thread.current[:consistent_random_context] = existing_context
41
+ Thread.current[:consistent_random_seed] = existing_seed
21
42
  end
22
43
  end
44
+
45
+ # Get the current seed used to generate random numbers. This will return nil if called
46
+ # outside of a scope.
47
+ #
48
+ # @return [String, nil] the seed value for the current scope
49
+ # or nil if called outside of a scope.
50
+ # @api private
51
+ def current_seed
52
+ Thread.current[:consistent_random_seed]
53
+ end
23
54
  end
24
55
 
25
56
  # @param name [Object] a name used to identifuy a consistent random value
@@ -27,7 +58,9 @@ class ConsistentRandom
27
58
  @name = name
28
59
  end
29
60
 
30
- # Generate a random float. This method works the same as Kernel#rand.
61
+ # Generate a random number. The same number will be generated within a scope block.
62
+ # This method works the same as Kernel#rand. It will generate a consistent value even
63
+ # across Ruby versions and platforms.
31
64
  #
32
65
  # @param max [Integer, Range] the maximum value of the random float or a range indicating
33
66
  # the minimum and maximum values.
@@ -35,35 +68,73 @@ class ConsistentRandom
35
68
  # a number in that range. If max is an number, then it will be an integer between 0 and that
36
69
  # value. Otherwise, it will be a float between 0 and 1.
37
70
  def rand(max = nil)
38
- random.rand(max || 1.0)
71
+ value = seed / SEED_DIVISOR
72
+ case max
73
+ when nil
74
+ value
75
+ when Numeric
76
+ (value * max.to_i).to_i
77
+ when Range
78
+ cap_to_range(value, max)
79
+ end
39
80
  end
40
81
 
41
- # Generate a random integer. This method works the same as Random#bytes.
82
+ # Generate a random array of bytes. The same number will be generated within a scope block.
83
+ # This method works the same as Random#bytes.
42
84
  #
43
85
  # @param size [Integer] the number of bytes to generate.
44
86
  # @return [String] a string of random bytes.
45
87
  def bytes(size)
46
- random.bytes(size)
88
+ bytes = []
89
+ ((size + 19) / 20).times { |i| bytes << seed_hash("#{@name}#{i}").to_s }
90
+ bytes.join[0, size]
47
91
  end
48
92
 
49
- # Generate a random number generator for the given name. The generator will always
50
- # have the same seed within a scope.
93
+ # Generate a seed that can be used to generate random numbers. This seed will be
94
+ # return a consistent value when called within a scope.
51
95
  #
52
- # @return [Random] a random number generator
53
- def random
54
- Random.new(current_context.seed(@name))
96
+ # @return [Integer] a 64 bit integer for seeding random values
97
+ def seed
98
+ hash = seed_hash(@name)
99
+ hash.byteslice(0, 8).unpack1("Q>")
55
100
  end
56
101
 
57
102
  # @return [Boolean] true if the other object is a ConsistentRandom that returns
58
103
  # the same random number generator. If called outside of a scope, then it will
59
104
  # always return false.
60
105
  def ==(other)
61
- other.is_a?(self.class) && other.random = random
106
+ other.is_a?(self.class) && other.seed = seed
107
+ end
108
+
109
+ # Generate a random number generator for the given name. The generator will always
110
+ # have the same seed within a scope.
111
+ #
112
+ # This value is dependent on the Ruby Random class and may not generate consistent values
113
+ # across Ruby versions and platforms.
114
+ #
115
+ # @return [Random] a random number generator
116
+ def random
117
+ Random.new(seed)
62
118
  end
63
119
 
64
120
  private
65
121
 
66
- def current_context
67
- Thread.current[:consistent_random_context] || Context.new
122
+ def seed_hash(name)
123
+ random_seed = self.class.current_seed || SecureRandom.hex
124
+ Digest::SHA1.digest("#{random_seed}\x1C#{name}")
125
+ end
126
+
127
+ def cap_to_range(value, range)
128
+ min = range.begin
129
+ max = range.end
130
+ if min.nil? || max.nil?
131
+ raise ArgumentError, "Cannot generate random value for infinite range"
132
+ end
133
+
134
+ int_range = min.is_a?(Integer) && max.is_a?(Integer)
135
+ max += 1 if int_range && range.include?(max)
136
+
137
+ val = (value * (max - min)) + min
138
+ int_range ? val.to_i : val
68
139
  end
69
140
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: consistent_random
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-10-06 00:00:00.000000000 Z
11
+ date: 2024-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -37,8 +37,9 @@ files:
37
37
  - VERSION
38
38
  - consistent_random.gemspec
39
39
  - lib/consistent_random.rb
40
- - lib/consistent_random/context.rb
40
+ - lib/consistent_random/active_job.rb
41
41
  - lib/consistent_random/rack_middleware.rb
42
+ - lib/consistent_random/sidekiq_client_middleware.rb
42
43
  - lib/consistent_random/sidekiq_middleware.rb
43
44
  homepage: https://github.com/bdurand/consistent_random
44
45
  licenses:
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class ConsistentRandom
4
- class Context
5
- # @api private
6
- attr_reader :seeds
7
-
8
- # @param existing_context [Context, nil] Existing context to copy generators from
9
- def initialize(existing_context = nil)
10
- @seeds = (existing_context ? existing_context.seeds.dup : {})
11
- end
12
-
13
- # Return a random number generator for the given name and seed
14
- #
15
- # @param name [String] Name of the generator
16
- # @return [Random] Random number generator
17
- def seed(name)
18
- @seeds[name] ||= Random.new_seed
19
- end
20
- end
21
- end