consistent_random 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e59a824de4f8d0a629bf033a675d48f3f5d40b6ffda07f0cf32c8f39d50dc7a2
4
- data.tar.gz: '08df13b6bbc9005e38fef21e05768b23eb1c1cbe36cda08694a0ce16bd800baa'
3
+ metadata.gz: 1fd62d51e962f285e32463d2b1854f22a3d7fee49e7cb6f44cb3f09dc6af9654
4
+ data.tar.gz: f2dd72200a420d2b36dbae1336f477cafe971993772b0d5afac5a269856a3a7e
5
5
  SHA512:
6
- metadata.gz: 31a8b9bb4996cefee885f4c2c3c251931b86db801443bbe9cafc383c882e7e4ae8be133a8d1d6b5032dcd1422c9906b33c2813ad1f857a0e81b2c81074a7a29d
7
- data.tar.gz: 76ae572f27d9e1ca92216e77d878b75bd934f0c81a03abdb1e811181a49fdd0e22231ce1a236f019274701aa35d80c16d3e3e6a898a853df85294a82edb561bd
6
+ metadata.gz: 56111c5d7231bf39fadca5ef39f1bef04b23df47962646774355f18c9a543dfec3f74eb5e3ad0a32acd0db66d684f8852eb2161cca4c4d5bb5a10fe729726103
7
+ data.tar.gz: 8daa15435772ad90dcb34c1797edaece7dcf78c34f088a532b843fa9c371f062fb9cd969f99d8afe40702f11c8bcbd6f439d6efc71ab60a1b50aa72f92cfcf97
data/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ 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.3.0
8
+
9
+ ### Fixed
10
+
11
+ - `ConsistentRandom#rand` can no longer return an out of range value. Previously there was a very small chance (about 1 in 2⁵³) that a seed could produce a value of exactly 1.0, causing `rand` to return 1.0, `rand(n)` to return `n`, and `rand(range)` to return a value above the range. Random floats are now derived from the top 53 bits of the seed, guaranteeing a uniform value in `[0, 1)`. **Note:** this changes the exact values generated for existing seeds (the distribution is unchanged), so percentage rollouts in flight during an upgrade may reassign.
12
+ - Fixed distribution bias in `ConsistentRandom#rand` for ranges with negative bounds. Values were truncated toward zero instead of floored, so for a range like `-5..5` the minimum value was never returned and zero was returned twice as often as other values.
13
+ - `ConsistentRandom#rand` now raises an `ArgumentError` for empty ranges (e.g. `5..1`) and non-numeric ranges instead of returning out of range values or raising a `NoMethodError`.
14
+ - `ConsistentRandom::Testing#bytes` now raises an `ArgumentError` for empty strings, which were previously accepted but silently ignored, producing non-deterministic values in tests.
15
+
16
+ ### Changed
17
+
18
+ - `ConsistentRandom.current_seed` is now public API; it can be used to propagate a scope into threads or fibers, which do not inherit the fiber-local scope state.
19
+
20
+ ## 2.2.0
21
+
22
+ ### Added
23
+
24
+ - Added `ConsistentRandom.testing` method to allow for deterministic testing of random values.
25
+ - Added `ConsistentRandom#name` method to return the name used for seeding the random value.
26
+
7
27
  ## 2.1.1
8
28
 
9
29
  ### Fixed
data/README.md CHANGED
@@ -14,10 +14,12 @@ For example, consider rolling out a new feature to a subset of requests. You may
14
14
 
15
15
  ## Table of Contents
16
16
  - [Usage](#usage)
17
+ - [Threads and Fibers](#threads-and-fibers)
17
18
  - [Middlewares](#middlewares)
18
19
  - [Rack Middleware](#rack-middleware)
19
20
  - [Sidekiq Middleware](#sidekiq-middleware)
20
21
  - [ActiveJob](#activejob)
22
+ - [Testing](#testing)
21
23
  - [Installation](#installation)
22
24
  - [Contributing](#contributing)
23
25
  - [License](#license)
@@ -77,6 +79,24 @@ random = ConsistentRandom.new("foobar")
77
79
  random.rand != random.rand # => true
78
80
  ```
79
81
 
82
+ ### Threads and Fibers
83
+
84
+ Scopes are stored in fiber-local state, so they do not propagate to threads or fibers spawned inside the scope block. Code running in a `Thread.new` block, an `Enumerator` (which runs on its own fiber), or Rails' `load_async` will not see the surrounding scope and will generate independent random values.
85
+
86
+ If you need consistent values in spawned threads or fibers, capture the current seed and re-establish the scope inside them:
87
+
88
+ ```ruby
89
+ ConsistentRandom.scope do
90
+ seed = ConsistentRandom.current_seed
91
+
92
+ Thread.new do
93
+ ConsistentRandom.scope(seed) do
94
+ # Values here are consistent with the parent scope.
95
+ end
96
+ end
97
+ end
98
+ ```
99
+
80
100
  ## Middlewares
81
101
 
82
102
  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.
@@ -138,7 +158,7 @@ Sidekiq.configure_client do |config|
138
158
  end
139
159
  ```
140
160
 
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`:
161
+ Consistent random values will be propagated from the original request to any Sidekiq jobs so you will get consistent behavior on any asynchronous jobs. You can disable this behavior on a job by setting the `consistent_random` sidekiq option to `false`:
142
162
 
143
163
  ```ruby
144
164
  class MyWorker
@@ -206,6 +226,36 @@ class MyJob < ApplicationJob
206
226
  end
207
227
  ```
208
228
 
229
+ ## Testing
230
+
231
+ 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.
232
+
233
+ ```ruby
234
+ # Specify that all random values should be 0.5
235
+ ConsistentRandom.testing.rand(0.5) do
236
+ expect(ConsistentRandom.new("foo").rand).to eq(0.5)
237
+ expect(ConsistentRandom.new("bar").rand).to eq(0.5)
238
+
239
+ # The rand value must be between 0 and 1, but it will be scaled to fit
240
+ # any size or range specified for `rand`.
241
+ expect(ConsistentRandom.new("foo").rand(10)).to eq(5)
242
+ end
243
+
244
+ # You can also specify values for specific names.
245
+ # If a values isn't specified, it will return a random value.
246
+ ConsistentRandom.testing.rand(foo: 0.5, bar: 0.8) do
247
+ expect(ConsistentRandom.new("foo").rand).to eq(0.5)
248
+ expect(ConsistentRandom.new("bar").rand).to eq(0.8)
249
+ end
250
+
251
+ # You can also specify values for the `bytes` and `seed` methods. The methods
252
+ # for setting test valus can be chained together.
253
+ ConsistentRandom.testing.bytes(foo: "bar").seed(baz: 123) do
254
+ expect(ConsistentRandom.new("foo").bytes(6)).to eq("barbar")
255
+ expect(ConsistentRandom.new("baz").seed).to eq(123)
256
+ end
257
+ ```
258
+
209
259
  ## Installation
210
260
 
211
261
  Add this line to your application's Gemfile:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.1.1
1
+ 2.3.0
@@ -35,7 +35,7 @@ Gem::Specification.new do |spec|
35
35
 
36
36
  spec.require_paths = ["lib"]
37
37
 
38
- spec.required_ruby_version = ">= 2.5"
38
+ spec.required_ruby_version = ">= 2.6"
39
39
 
40
40
  spec.add_development_dependency "bundler"
41
41
  end
@@ -2,10 +2,12 @@
2
2
 
3
3
  class ConsistentRandom
4
4
  module ActiveJob
5
- def self.included(base)
6
- attr_reader :consistent_random_seeds
5
+ # @return [String, nil] the seed deserialized from the job data
6
+ # @api private
7
+ attr_reader :consistent_random_seed
7
8
 
8
- base.around_perform :peform_with_consistent_random_scope
9
+ def self.included(base)
10
+ base.around_perform :perform_with_consistent_random_scope
9
11
 
10
12
  base.class_attribute :inherit_consistent_random_scope, instance_writer: false
11
13
  end
@@ -21,14 +23,14 @@ class ConsistentRandom
21
23
 
22
24
  def deserialize(job_data)
23
25
  super
24
- @consistent_random_seeds = job_data["consistent_random_seed"]
26
+ @consistent_random_seed = job_data["consistent_random_seed"]
25
27
  end
26
28
 
27
29
  private
28
30
 
29
- def peform_with_consistent_random_scope(&block)
30
- seeds = consistent_random_seeds unless inherit_consistent_random_scope == false
31
- ConsistentRandom.scope(seeds, &block)
31
+ def perform_with_consistent_random_scope(&block)
32
+ seed = consistent_random_seed unless inherit_consistent_random_scope == false
33
+ ConsistentRandom.scope(seed, &block)
32
34
  end
33
35
  end
34
36
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConsistentRandom
4
+ # This class returns an object that can be used to generate deterministic values
5
+ # for use in testing.
6
+ #
7
+ # @example
8
+ # ConsistentRandom.testing.rand(foo: 0.5).bytes(foo: "foobar").seed(123) do
9
+ # expect(ConsistentRandom.new("foo").rand).to eq(0.5)
10
+ # expect(ConsistentRandom.new("bar").rand).not_to eq(0.5)
11
+ #
12
+ # expect(ConsistentRandom.new("foo").bytes(12)).to eq("foobarfoobar")
13
+ #
14
+ # expect(ConsistentRandom.new("foo").seed).to eq(123)
15
+ # end
16
+ class ConsistentRandom::Testing
17
+ class << self
18
+ # Get the testing object if any for the current block.
19
+ #
20
+ # @return [ConsistentRandom::Testing, nil] the testing object or nil if not set
21
+ # @api private
22
+ def current
23
+ Thread.current[:consistent_random_testing]
24
+ end
25
+ end
26
+
27
+ def initialize
28
+ @rand_hash = {}
29
+ @bytes_hash = {}
30
+ @seed_hash = {}
31
+ end
32
+
33
+ # Set the random value returned by ConsistentRandom#rand.
34
+ #
35
+ # param options [Float, Hash<String, Float>] the value to return for rand. If a Float is given
36
+ # then it will be used as the value for all calls to rand. If a Hash is given then the value
37
+ # will only be returned for ConsitentRandom objects with the names specified in the keys.
38
+ # @yield block of code to execute with the test values.
39
+ # @return [ConsistentRandom::Testing, Object] If a block is specified, then the result
40
+ # of the block is returned. Otherwise the testing object is returned so that you can
41
+ # chain calls to set up more test values.
42
+ def rand(options, &block)
43
+ options = validate_rand(options)
44
+ unless options
45
+ raise ArgumentError.new("Argument must be a Float between 0 and 1 or a Hash with Float values")
46
+ end
47
+
48
+ @rand_hash = options.default ? options.merge(@rand_hash) : @rand_hash.merge(options)
49
+ if block
50
+ use(&block)
51
+ else
52
+ self
53
+ end
54
+ end
55
+
56
+ # Set the random bytes returned by ConsistentRandom#bytes.
57
+ #
58
+ # param options [String, Hash<String, String>] the value to return for bytes. If a String is given
59
+ # then it will be used as the value for all calls to bytes. If a Hash is given then the value
60
+ # will only be returned for ConsitentRandom objects with the names specified in the keys.
61
+ # @yield block of code to execute with the test values.
62
+ # @return [ConsistentRandom::Testing, Object] If a block is specified, then the result
63
+ # of the block is returned. Otherwise the testing object is returned so that you can
64
+ # chain calls to set up more test values.
65
+ def bytes(options, &block)
66
+ options = validate_bytes(options)
67
+ unless options
68
+ raise ArgumentError.new("Argument must be a non-empty String or a Hash with non-empty String values")
69
+ end
70
+
71
+ @bytes_hash = options.default ? options.merge(@bytes_hash) : @bytes_hash.merge(options)
72
+ if block
73
+ use(&block)
74
+ else
75
+ self
76
+ end
77
+ end
78
+
79
+ # Set the seed value returned by ConsistentRandom#seed.
80
+ #
81
+ # param options [Integer, Hash<String, Integer>] the value to return for seed. If an Integer is given
82
+ # then it will be used as the value for all calls to seed. If a Hash is given then the value
83
+ # will only be returned for ConsitentRandom objects with the names specified in the keys.
84
+ # @yield block of code to execute with the test values.
85
+ # @return [ConsistentRandom::Testing, Object] If a block is specified, then the result
86
+ # of the block is returned. Otherwise the testing object is returned so that you can
87
+ # chain calls to set up more test
88
+ def seed(options, &block)
89
+ options = validate_seed(options)
90
+ unless options
91
+ raise ArgumentError.new("Argument must be an Integer or a Hash with Integer values")
92
+ end
93
+
94
+ @seed_hash = options.default ? options.merge(@seed_hash) : @seed_hash.merge(options)
95
+ if block
96
+ use(&block)
97
+ else
98
+ self
99
+ end
100
+ end
101
+
102
+ # Use the test values within a block of code.
103
+ #
104
+ # @yield block of code to execute with the test values.
105
+ # @return [Object] the result of the block
106
+ def use(&block)
107
+ save_val = Thread.current[:consistent_random_testing]
108
+ begin
109
+ Thread.current[:consistent_random_testing] = self
110
+ yield
111
+ ensure
112
+ Thread.current[:consistent_random_testing] = save_val
113
+ end
114
+ end
115
+
116
+ # Get the test value for rand.
117
+ #
118
+ # @param name [String] the name of the ConsistentRandom object
119
+ # @return [Float, nil] the test value for rand if there is a test value for the name
120
+ # @api private
121
+ def rand_for(name)
122
+ @rand_hash[name]
123
+ end
124
+
125
+ # Get the test value for bytes.
126
+ #
127
+ # @param name [String] the name of the ConsistentRandom object
128
+ # @return [String, nil] the test value for bytes if there is a test value for the name
129
+ # @api private
130
+ def bytes_for(name)
131
+ @bytes_hash[name]
132
+ end
133
+
134
+ # Get the test value for seed.
135
+ #
136
+ # @param name [String] the name of the ConsistentRandom object
137
+ # @return [Integer, nil] the test value for seed if there is a test value for the name
138
+ # @api private
139
+ def seed_for(name)
140
+ @seed_hash[name]
141
+ end
142
+
143
+ private
144
+
145
+ def validate_rand(options)
146
+ if options.is_a?(Hash) && options.values.all? { |value| value.is_a?(Float) && (0...1).cover?(value) }
147
+ options.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value }
148
+ elsif options.is_a?(Float) && (0...1).cover?(options)
149
+ Hash.new(options)
150
+ end
151
+ end
152
+
153
+ def validate_bytes(options)
154
+ if options.is_a?(Hash) && options.values.all? { |value| value.is_a?(String) && !value.empty? }
155
+ options.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.encode(Encoding::ASCII_8BIT) }
156
+ elsif options.is_a?(String) && !options.empty?
157
+ Hash.new(options)
158
+ end
159
+ end
160
+
161
+ def validate_seed(options)
162
+ if options.is_a?(Hash) && options.values.all? { |value| value.is_a?(Integer) }
163
+ options.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value }
164
+ elsif options.is_a?(Integer)
165
+ Hash.new(options)
166
+ end
167
+ end
168
+ end
169
+ end
@@ -4,8 +4,11 @@ require "digest/sha1"
4
4
  require "securerandom"
5
5
 
6
6
  class ConsistentRandom
7
- SEED_DIVISOR = (2**64 - 1).to_f
8
- private_constant :SEED_DIVISOR
7
+ # Divisor for converting the top 53 bits of a seed into a Float. Using 53 bits
8
+ # ensures the result is exactly representable so values are uniform in [0, 1)
9
+ # and can never round up to 1.0.
10
+ FLOAT_DIVISOR = 2.0**53
11
+ private_constant :FLOAT_DIVISOR
9
12
 
10
13
  autoload :RackMiddleware, "consistent_random/rack_middleware"
11
14
  autoload :SidekiqMiddleware, "consistent_random/sidekiq_middleware"
@@ -15,7 +18,7 @@ class ConsistentRandom
15
18
  class << self
16
19
  # Define a scope where consistent random values will be generated.
17
20
  #
18
- # @param seeds [String, Symbol, Integer, Array<String>, Array<Integer>, nil] optional value to
21
+ # @param seed [String, Symbol, Integer, Array<String>, Array<Integer>, nil] optional value to
19
22
  # use for generating random numbers. By default a random value will be generated. If the
20
23
  # scope is nested in another scope block, then the seed from the parent scope will be used
21
24
  # by default.
@@ -29,7 +32,7 @@ class ConsistentRandom
29
32
  when String, Symbol, Integer
30
33
  seed.to_s
31
34
  when Array
32
- seed.map { |s| s.to_s }.join("\x1C")
35
+ seed.join("\x1C")
33
36
  else
34
37
  raise ArgumentError, "Invalid seed value: #{seed.inspect}"
35
38
  end
@@ -43,19 +46,25 @@ class ConsistentRandom
43
46
  end
44
47
 
45
48
  # Get the current seed used to generate random numbers. This will return nil if called
46
- # outside of a scope.
49
+ # outside of a scope. The value can be passed to a new scope to generate the same
50
+ # random values in another thread or fiber.
47
51
  #
48
52
  # @return [String, nil] the seed value for the current scope
49
53
  # or nil if called outside of a scope.
50
- # @api private
51
54
  def current_seed
52
55
  Thread.current[:consistent_random_seed]
53
56
  end
57
+
58
+ def testing
59
+ Testing.new
60
+ end
54
61
  end
55
62
 
63
+ attr_reader :name
64
+
56
65
  # @param name [Object] a name used to identifuy a consistent random value
57
66
  def initialize(name)
58
- @name = name
67
+ @name = (name.is_a?(String) ? name.dup : name.to_s).freeze
59
68
  end
60
69
 
61
70
  # Generate a random number. The same number will be generated within a scope block.
@@ -68,7 +77,7 @@ class ConsistentRandom
68
77
  # a number in that range. If max is an number, then it will be an integer between 0 and that
69
78
  # value. Otherwise, it will be a float between 0 and 1.
70
79
  def rand(max = nil)
71
- value = seed / SEED_DIVISOR
80
+ value = Testing.current&.rand_for(name) || (seed >> 11) / FLOAT_DIVISOR
72
81
  case max
73
82
  when nil
74
83
  value
@@ -85,8 +94,13 @@ class ConsistentRandom
85
94
  # @param size [Integer] the number of bytes to generate.
86
95
  # @return [String] a string of random bytes.
87
96
  def bytes(size)
97
+ test_bytes = Testing.current&.bytes_for(name)
98
+ chunk_size = (test_bytes ? test_bytes.length : 20)
99
+
88
100
  bytes = []
89
- ((size + 19) / 20).times { |i| bytes << seed_hash("#{@name}#{i}").to_s }
101
+ (size / chunk_size.to_f).ceil.times do |i|
102
+ bytes << (test_bytes || seed_hash("#{name}#{i}").to_s)
103
+ end
90
104
  bytes.join[0, size]
91
105
  end
92
106
 
@@ -95,13 +109,17 @@ class ConsistentRandom
95
109
  #
96
110
  # @return [Integer] a 64 bit integer for seeding random values
97
111
  def seed
98
- hash = seed_hash(@name)
112
+ test_seed = Testing.current&.seed_for(name)
113
+ return test_seed if test_seed
114
+
115
+ hash = seed_hash(name)
99
116
  hash.byteslice(0, 8).unpack1("Q>")
100
117
  end
101
118
 
102
119
  # @return [Boolean] true if the other object is a ConsistentRandom that returns
103
120
  # the same random number generator. If called outside of a scope, then it will
104
- # always return false.
121
+ # always return false. The functionality is designed to be similar to the
122
+ # same behavior as Random.
105
123
  def ==(other)
106
124
  other.is_a?(self.class) && other.seed == seed
107
125
  end
@@ -131,10 +149,20 @@ class ConsistentRandom
131
149
  raise ArgumentError, "Cannot generate random value for infinite range"
132
150
  end
133
151
 
152
+ unless min.is_a?(Numeric) && max.is_a?(Numeric)
153
+ raise ArgumentError, "Cannot generate random value for non-numeric range"
154
+ end
155
+
156
+ if min > max || (range.exclude_end? && min == max)
157
+ raise ArgumentError, "Cannot generate random value for empty range"
158
+ end
159
+
134
160
  int_range = min.is_a?(Integer) && max.is_a?(Integer)
135
161
  max += 1 if int_range && range.include?(max)
136
162
 
137
163
  val = (value * (max - min)) + min
138
- int_range ? val.to_i : val
164
+ int_range ? val.floor : val
139
165
  end
140
166
  end
167
+
168
+ require_relative "consistent_random/testing"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: consistent_random
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-11-20 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bundler
@@ -24,7 +23,6 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '0'
27
- description:
28
26
  email:
29
27
  - bbdurand@gmail.com
30
28
  executables: []
@@ -41,6 +39,7 @@ files:
41
39
  - lib/consistent_random/rack_middleware.rb
42
40
  - lib/consistent_random/sidekiq_client_middleware.rb
43
41
  - lib/consistent_random/sidekiq_middleware.rb
42
+ - lib/consistent_random/testing.rb
44
43
  homepage: https://github.com/bdurand/consistent_random
45
44
  licenses:
46
45
  - MIT
@@ -48,7 +47,6 @@ metadata:
48
47
  homepage_uri: https://github.com/bdurand/consistent_random
49
48
  source_code_uri: https://github.com/bdurand/consistent_random
50
49
  changelog_uri: https://github.com/bdurand/consistent_random/blob/main/CHANGELOG.md
51
- post_install_message:
52
50
  rdoc_options: []
53
51
  require_paths:
54
52
  - lib
@@ -56,15 +54,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
56
54
  requirements:
57
55
  - - ">="
58
56
  - !ruby/object:Gem::Version
59
- version: '2.5'
57
+ version: '2.6'
60
58
  required_rubygems_version: !ruby/object:Gem::Requirement
61
59
  requirements:
62
60
  - - ">="
63
61
  - !ruby/object:Gem::Version
64
62
  version: '0'
65
63
  requirements: []
66
- rubygems_version: 3.4.10
67
- signing_key:
64
+ rubygems_version: 4.0.3
68
65
  specification_version: 4
69
66
  summary: Generates consistent random values within a defined scope, ensuring deterministic
70
67
  behavior for use in feature rollouts and other scoped operations.