consistent_random 2.2.0 → 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: 87b396dd6b089d0fb4c99777531eb0824a4d78b2180bce9eeee6484efd06ddbd
4
- data.tar.gz: f5ade642f1222468d68ca326cfad4a206b9e74b97fe786e2497d13e9a67a0459
3
+ metadata.gz: 1fd62d51e962f285e32463d2b1854f22a3d7fee49e7cb6f44cb3f09dc6af9654
4
+ data.tar.gz: f2dd72200a420d2b36dbae1336f477cafe971993772b0d5afac5a269856a3a7e
5
5
  SHA512:
6
- metadata.gz: 8ab235ba4d2b6b447e05614cd603c739461e1d3e0efad6c088ff07b966b6daf207f76c413a49dc00d78125ee12f1ca252b91cba01154d00441bc03dd00523628
7
- data.tar.gz: 4a5ca92427978a6f496bc75b057c6982bf7a9eac870eef9139ab5e1cce36aab236c2da6acaf50b551d483d696ce6235d063ba4883a37110569b8f4395571e5e7
6
+ metadata.gz: 56111c5d7231bf39fadca5ef39f1bef04b23df47962646774355f18c9a543dfec3f74eb5e3ad0a32acd0db66d684f8852eb2161cca4c4d5bb5a10fe729726103
7
+ data.tar.gz: 8daa15435772ad90dcb34c1797edaece7dcf78c34f088a532b843fa9c371f062fb9cd969f99d8afe40702f11c8bcbd6f439d6efc71ab60a1b50aa72f92cfcf97
data/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ 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
+
7
20
  ## 2.2.0
8
21
 
9
22
  ### Added
data/README.md CHANGED
@@ -14,6 +14,7 @@ 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)
@@ -78,6 +79,24 @@ random = ConsistentRandom.new("foobar")
78
79
  random.rand != random.rand # => true
79
80
  ```
80
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
+
81
100
  ## Middlewares
82
101
 
83
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.
@@ -139,7 +158,7 @@ Sidekiq.configure_client do |config|
139
158
  end
140
159
  ```
141
160
 
142
- 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`:
143
162
 
144
163
  ```ruby
145
164
  class MyWorker
@@ -224,7 +243,7 @@ end
224
243
 
225
244
  # You can also specify values for specific names.
226
245
  # If a values isn't specified, it will return a random value.
227
- ConsistentRandom.testing(foo: 0.5, bar: 0.8) do
246
+ ConsistentRandom.testing.rand(foo: 0.5, bar: 0.8) do
228
247
  expect(ConsistentRandom.new("foo").rand).to eq(0.5)
229
248
  expect(ConsistentRandom.new("bar").rand).to eq(0.8)
230
249
  end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.2.0
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
@@ -65,7 +65,7 @@ class ConsistentRandom
65
65
  def bytes(options, &block)
66
66
  options = validate_bytes(options)
67
67
  unless options
68
- raise ArgumentError.new("Argument must be a String or a Hash with String values")
68
+ raise ArgumentError.new("Argument must be a non-empty String or a Hash with non-empty String values")
69
69
  end
70
70
 
71
71
  @bytes_hash = options.default ? options.merge(@bytes_hash) : @bytes_hash.merge(options)
@@ -151,9 +151,9 @@ class ConsistentRandom
151
151
  end
152
152
 
153
153
  def validate_bytes(options)
154
- if options.is_a?(Hash) && options.values.all? { |value| value.is_a?(String) }
154
+ if options.is_a?(Hash) && options.values.all? { |value| value.is_a?(String) && !value.empty? }
155
155
  options.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.encode(Encoding::ASCII_8BIT) }
156
- elsif options.is_a?(String)
156
+ elsif options.is_a?(String) && !options.empty?
157
157
  Hash.new(options)
158
158
  end
159
159
  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,11 +46,11 @@ 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
@@ -74,7 +77,7 @@ class ConsistentRandom
74
77
  # a number in that range. If max is an number, then it will be an integer between 0 and that
75
78
  # value. Otherwise, it will be a float between 0 and 1.
76
79
  def rand(max = nil)
77
- value = Testing.current&.rand_for(name) || seed / SEED_DIVISOR
80
+ value = Testing.current&.rand_for(name) || (seed >> 11) / FLOAT_DIVISOR
78
81
  case max
79
82
  when nil
80
83
  value
@@ -92,7 +95,6 @@ class ConsistentRandom
92
95
  # @return [String] a string of random bytes.
93
96
  def bytes(size)
94
97
  test_bytes = Testing.current&.bytes_for(name)
95
- test_bytes = nil if test_bytes&.empty?
96
98
  chunk_size = (test_bytes ? test_bytes.length : 20)
97
99
 
98
100
  bytes = []
@@ -147,11 +149,19 @@ class ConsistentRandom
147
149
  raise ArgumentError, "Cannot generate random value for infinite range"
148
150
  end
149
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
+
150
160
  int_range = min.is_a?(Integer) && max.is_a?(Integer)
151
161
  max += 1 if int_range && range.include?(max)
152
162
 
153
163
  val = (value * (max - min)) + min
154
- int_range ? val.to_i : val
164
+ int_range ? val.floor : val
155
165
  end
156
166
  end
157
167
 
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.2.0
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: []
@@ -49,7 +47,6 @@ metadata:
49
47
  homepage_uri: https://github.com/bdurand/consistent_random
50
48
  source_code_uri: https://github.com/bdurand/consistent_random
51
49
  changelog_uri: https://github.com/bdurand/consistent_random/blob/main/CHANGELOG.md
52
- post_install_message:
53
50
  rdoc_options: []
54
51
  require_paths:
55
52
  - lib
@@ -57,15 +54,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
57
54
  requirements:
58
55
  - - ">="
59
56
  - !ruby/object:Gem::Version
60
- version: '2.5'
57
+ version: '2.6'
61
58
  required_rubygems_version: !ruby/object:Gem::Requirement
62
59
  requirements:
63
60
  - - ">="
64
61
  - !ruby/object:Gem::Version
65
62
  version: '0'
66
63
  requirements: []
67
- rubygems_version: 3.4.10
68
- signing_key:
64
+ rubygems_version: 4.0.3
69
65
  specification_version: 4
70
66
  summary: Generates consistent random values within a defined scope, ensuring deterministic
71
67
  behavior for use in feature rollouts and other scoped operations.