throttle_machines 0.2.4 → 0.3.1

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: 6450b4499ed28a6520bdc5d51a2c5bc7fcc55ba8e1f1e327def0ca9ab5c80d62
4
- data.tar.gz: af1addd1f524c9f5663f3f241ce15bfb1aa9a6b5365463f3c9d453a9e03f0b05
3
+ metadata.gz: 3dd617a00122348c117c538ed1e1b2242763a619c28fed7bc6b701a4440c83fd
4
+ data.tar.gz: 692ac18d365391f16c16b81292672f223024e00dc92abd9c8a50020cf04a17bc
5
5
  SHA512:
6
- metadata.gz: 564076859ef7219e48060400a26295cffc830a74fbf82d757766753e19d2020fce390ce8b91b9daf996147689dd47fec0911037a34a0208cd160ec3d6009ac70
7
- data.tar.gz: 39590d460f59b66a700487146ea3309aa8df4d9f3b31e0248acb1b0748bc04b3a26f9a55f52f18b4beb3fca7618a8142092b4d434f45a4ce833a265d8e2c5fdb
6
+ metadata.gz: e2274c749842a2824ffdb505efd67545f48aec77f6492d9f72b798c275ab4cdf88d8356e5464cf5e3c5f1c461a850de9e659b57e9c2d02d88118e90c35cf94c9
7
+ data.tar.gz: 1853d52571d2bf67d5563126b3317a270b00553edb34170fbf70a28da8c9c961d0cfe92825090a0b87dd7d311b968f41372a09e74d1379edc3fbd2fec2ccf349
@@ -57,8 +57,28 @@ module ThrottleMachines
57
57
  nil
58
58
  end
59
59
 
60
+ # Rounding slack for time comparisons, in units of the clock's precision.
61
+ # Matches the native core.
62
+ SLACK_ULPS = 64
63
+
60
64
  protected
61
65
 
66
+ # Times are running sums and differences of float seconds, so an
67
+ # on-schedule arrival can miss an exact boundary by a few ulps. The slack
68
+ # scales with the clock's magnitude.
69
+ def rounding_slack(time)
70
+ Float::EPSILON * SLACK_ULPS * [time.abs, 1.0].max
71
+ end
72
+
73
+ def gcra_allowed?(tat, now, delay_tolerance)
74
+ tat - now <= delay_tolerance + rounding_slack(tat)
75
+ end
76
+
77
+ # Whether a token is available, allowing for rounding in the refill.
78
+ def token_available?(tokens, now, refill_rate)
79
+ tokens >= 1 - (rounding_slack(now) * refill_rate)
80
+ end
81
+
62
82
  def current_time
63
83
  # Use monotonic time for consistency with BreakerMachines
64
84
  ThrottleMachines.monotonic_time
@@ -86,7 +86,7 @@ module ThrottleMachines
86
86
  state = @gcra_states[key] || { tat: 0.0 }
87
87
 
88
88
  tat = [state[:tat], now].max
89
- allow = tat - now <= delay_tolerance
89
+ allow = gcra_allowed?(tat, now, delay_tolerance)
90
90
 
91
91
  if allow
92
92
  new_tat = tat + emission_interval
@@ -107,7 +107,7 @@ module ThrottleMachines
107
107
  state = @gcra_states[key] || { tat: 0.0 }
108
108
 
109
109
  tat = [state[:tat], now].max
110
- allow = tat - now <= delay_tolerance
110
+ allow = gcra_allowed?(tat, now, delay_tolerance)
111
111
 
112
112
  {
113
113
  allowed: allow,
@@ -130,8 +130,8 @@ module ThrottleMachines
130
130
  bucket[:last_refill] = now
131
131
 
132
132
  # Check if we can consume a token
133
- if bucket[:tokens] >= 1
134
- bucket[:tokens] -= 1
133
+ if token_available?(bucket[:tokens], now, refill_rate)
134
+ bucket[:tokens] = [bucket[:tokens] - 1, 0.0].max
135
135
  @token_buckets[key] = bucket.merge(expires_at: now + ttl)
136
136
 
137
137
  {
@@ -161,11 +161,11 @@ module ThrottleMachines
161
161
  tokens_to_add = elapsed * refill_rate
162
162
  current_tokens = [bucket[:tokens] + tokens_to_add, capacity].min
163
163
 
164
- if current_tokens >= 1
164
+ if token_available?(current_tokens, now, refill_rate)
165
165
  {
166
166
  allowed: true,
167
167
  retry_after: 0,
168
- tokens_remaining: (current_tokens - 1).floor
168
+ tokens_remaining: [current_tokens - 1, 0].max.floor
169
169
  }
170
170
  else
171
171
  retry_after = (1 - current_tokens) / refill_rate
@@ -12,11 +12,15 @@ else
12
12
  end
13
13
 
14
14
  tat = math.max(tat, now)
15
- local allow = (tat - now) <= delay_tolerance
15
+ -- The TAT is a running sum of emission intervals, so an on-schedule arrival
16
+ -- can overshoot the tolerance by a few ulps; the slack scales with the clock.
17
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
18
+ local allow = (tat - now) <= delay_tolerance + slack
16
19
 
17
20
  if allow then
18
21
  local new_tat = tat + emission_interval
19
22
  redis.call('SET', key, new_tat, 'EX', ttl)
20
23
  end
21
24
 
22
- return { allow and 1 or 0, tat }
25
+ -- Redis truncates Lua numbers in replies to integers; send the TAT as text.
26
+ return { allow and 1 or 0, string.format('%.17g', tat) }
@@ -11,6 +11,10 @@ else
11
11
  end
12
12
 
13
13
  tat = math.max(tat, now)
14
- local allow = (tat - now) <= delay_tolerance
14
+ -- The TAT is a running sum of emission intervals, so an on-schedule arrival
15
+ -- can overshoot the tolerance by a few ulps; the slack scales with the clock.
16
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
17
+ local allow = (tat - now) <= delay_tolerance + slack
15
18
 
16
- return { allow and 1 or 0, tat }
19
+ -- Redis truncates Lua numbers in replies to integers; send the TAT as text.
20
+ return { allow and 1 or 0, string.format('%.17g', tat) }
@@ -12,7 +12,13 @@ local elapsed = now - last_refill
12
12
  local tokens_to_add = elapsed * refill_rate
13
13
  tokens = math.min(tokens + tokens_to_add, capacity)
14
14
 
15
- local allow = tokens >= 1
16
- local tokens_after = allow and (tokens - 1) or 0
15
+ -- `elapsed` carries the clock's rounding; a token due exactly now may come
16
+ -- out a hair short of 1. The slack scales with the clock, as in the native core.
17
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
18
+ local allow = tokens >= 1 - slack
19
+ -- Admitted: tokens left after this request. Refused: the fractional balance,
20
+ -- which retry_after is computed from.
21
+ local tokens_after = allow and math.max(tokens - 1, 0) or tokens
17
22
 
18
- return { allow and 1 or 0, tokens_after }
23
+ -- Redis truncates Lua numbers in replies to integers; send the count as text.
24
+ return { allow and 1 or 0, string.format('%.17g', tokens_after) }
@@ -13,11 +13,15 @@ local elapsed = now - last_refill
13
13
  local tokens_to_add = elapsed * refill_rate
14
14
  tokens = math.min(tokens + tokens_to_add, capacity)
15
15
 
16
- local allow = tokens >= 1
16
+ -- `elapsed` carries the clock's rounding; a token due exactly now may come
17
+ -- out a hair short of 1. The slack scales with the clock, as in the native core.
18
+ local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
19
+ local allow = tokens >= 1 - slack
17
20
  if allow then
18
- tokens = tokens - 1
21
+ tokens = math.max(tokens - 1, 0)
19
22
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
20
23
  redis.call('EXPIRE', key, ttl)
21
24
  end
22
25
 
23
- return { allow and 1 or 0, tokens }
26
+ -- Redis truncates Lua numbers in replies to integers; send the count as text.
27
+ return { allow and 1 or 0, string.format('%.17g', tokens) }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest/sha1'
4
+
3
5
  module ThrottleMachines
4
6
  module Storage
5
7
  class Redis < Base
@@ -13,17 +15,18 @@ module ThrottleMachines
13
15
  INCREMENT_COUNTER_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'increment_counter.lua'))
14
16
  # Breaker scripts removed: breaker state is owned by BreakerMachines
15
17
 
18
+ # Redis caches scripts by the SHA1 of their source, so the SHAs are known
19
+ # without a SCRIPT LOAD round trip.
20
+ GCRA_SHA = Digest::SHA1.hexdigest(GCRA_SCRIPT)
21
+ TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(TOKEN_BUCKET_SCRIPT)
22
+ PEEK_GCRA_SHA = Digest::SHA1.hexdigest(PEEK_GCRA_SCRIPT)
23
+ PEEK_TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(PEEK_TOKEN_BUCKET_SCRIPT)
24
+
16
25
  def initialize(options = {})
17
26
  super
18
27
  @redis = options[:redis] || options[:client] || options[:pool]
19
28
  @prefix = options[:prefix] || 'throttle:'
20
29
 
21
- # Cache scripts to avoid repeated script loads
22
- @gcra_sha = nil
23
- @token_bucket_sha = nil
24
- @peek_gcra_sha = nil
25
- @peek_token_bucket_sha = nil
26
-
27
30
  # Validate Redis connection
28
31
  validate_redis_connection!
29
32
  end
@@ -56,111 +59,53 @@ module ThrottleMachines
56
59
 
57
60
  # GCRA operations (atomic via Lua)
58
61
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
59
- ensure_gcra_script_loaded!
60
-
61
- result = with_redis do |redis|
62
- redis.evalsha(
63
- @gcra_sha,
64
- keys: [prefixed(key)],
65
- argv: [emission_interval, delay_tolerance, ttl, current_time]
66
- )
67
- end
62
+ now = current_time
63
+ result = run_script(GCRA_SCRIPT, GCRA_SHA,
64
+ keys: [prefixed(key)],
65
+ argv: [emission_interval, delay_tolerance, ttl, now])
68
66
 
69
67
  allowed = result[0] == 1
70
- tat = result[1]
71
- now = current_time
68
+ tat = Float(result[1])
72
69
 
73
70
  {
74
71
  allowed: allowed,
75
72
  retry_after: allowed ? 0 : (tat - now - delay_tolerance),
76
73
  tat: tat
77
74
  }
78
- rescue ::Redis::CommandError => e
79
- raise unless e.message.include?('NOSCRIPT')
80
-
81
- @gcra_sha = nil
82
- retry
83
75
  end
84
76
 
85
77
  # Token bucket operations (atomic via Lua)
86
78
  def check_token_bucket(key, capacity, refill_rate, ttl)
87
- ensure_token_bucket_script_loaded!
88
-
89
- result = with_redis do |redis|
90
- redis.evalsha(
91
- @token_bucket_sha,
92
- keys: [prefixed(key)],
93
- argv: [capacity, refill_rate, ttl, current_time]
94
- )
95
- end
96
-
97
- allowed = result[0] == 1
98
- tokens = result[1]
79
+ result = run_script(TOKEN_BUCKET_SCRIPT, TOKEN_BUCKET_SHA,
80
+ keys: [prefixed(key)],
81
+ argv: [capacity, refill_rate, ttl, current_time])
99
82
 
100
- {
101
- allowed: allowed,
102
- retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
103
- tokens_remaining: tokens.floor
104
- }
105
- rescue ::Redis::CommandError => e
106
- raise unless e.message.include?('NOSCRIPT')
107
-
108
- @token_bucket_sha = nil
109
- retry
83
+ token_bucket_result(result, refill_rate)
110
84
  end
111
85
 
112
86
  # Peek methods for non-consuming checks
113
87
  def peek_gcra_limit(key, emission_interval, delay_tolerance)
114
- ensure_peek_gcra_script_loaded!
115
-
116
- result = with_redis do |redis|
117
- redis.evalsha(
118
- @peek_gcra_sha,
119
- keys: [prefixed(key)],
120
- argv: [emission_interval, delay_tolerance, current_time]
121
- )
122
- end
88
+ now = current_time
89
+ result = run_script(PEEK_GCRA_SCRIPT, PEEK_GCRA_SHA,
90
+ keys: [prefixed(key)],
91
+ argv: [emission_interval, delay_tolerance, now])
123
92
 
124
93
  allowed = result[0] == 1
125
- tat = result[1]
126
- now = current_time
94
+ tat = Float(result[1])
127
95
 
128
96
  {
129
97
  allowed: allowed,
130
98
  retry_after: allowed ? 0 : (tat - now - delay_tolerance),
131
99
  tat: tat
132
100
  }
133
- rescue ::Redis::CommandError => e
134
- raise unless e.message.include?('NOSCRIPT')
135
-
136
- @peek_gcra_sha = nil
137
- retry
138
101
  end
139
102
 
140
103
  def peek_token_bucket(key, capacity, refill_rate)
141
- ensure_peek_token_bucket_script_loaded!
142
-
143
- result = with_redis do |redis|
144
- redis.evalsha(
145
- @peek_token_bucket_sha,
146
- keys: [prefixed(key)],
147
- argv: [capacity, refill_rate, current_time]
148
- )
149
- end
104
+ result = run_script(PEEK_TOKEN_BUCKET_SCRIPT, PEEK_TOKEN_BUCKET_SHA,
105
+ keys: [prefixed(key)],
106
+ argv: [capacity, refill_rate, current_time])
150
107
 
151
- allowed = result[0] == 1
152
- tokens_remaining = result[1]
153
-
154
- {
155
- allowed: allowed,
156
- retry_after: allowed ? 0 : (1 - tokens_remaining) / refill_rate,
157
- tokens_remaining: tokens_remaining.floor
158
- }
159
- rescue ::Redis::CommandError => e
160
- raise unless e.message.include?('NOSCRIPT')
161
-
162
- @peek_token_bucket_sha = nil
163
- retry
108
+ token_bucket_result(result, refill_rate)
164
109
  end
165
110
 
166
111
  # No circuit breaker operations: breaker state is owned by BreakerMachines
@@ -188,24 +133,34 @@ module ThrottleMachines
188
133
 
189
134
  private
190
135
 
191
- def prefixed(key)
192
- "#{@prefix}#{key}"
193
- end
136
+ # `result` is [allowed, tokens]: the balance after an admission, or the
137
+ # fractional balance a refusal waits on. Matches Storage::Memory.
138
+ def token_bucket_result(result, refill_rate)
139
+ allowed = result[0] == 1
140
+ tokens = Float(result[1])
194
141
 
195
- def ensure_gcra_script_loaded!
196
- @ensure_gcra_script_loaded ||= with_redis { |r| r.script(:load, GCRA_SCRIPT) }
142
+ {
143
+ allowed: allowed,
144
+ retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
145
+ tokens_remaining: allowed ? tokens.floor : 0
146
+ }
197
147
  end
198
148
 
199
- def ensure_token_bucket_script_loaded!
200
- @ensure_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, TOKEN_BUCKET_SCRIPT) }
149
+ def prefixed(key)
150
+ "#{@prefix}#{key}"
201
151
  end
202
152
 
203
- def ensure_peek_gcra_script_loaded!
204
- @ensure_peek_gcra_script_loaded ||= with_redis { |r| r.script(:load, PEEK_GCRA_SCRIPT) }
205
- end
153
+ # EVALSHA by the precomputed SHA. A server without the script cached (a
154
+ # restart, SCRIPT FLUSH, failover) answers NOSCRIPT; EVAL then runs the
155
+ # source once and caches it for later calls. Any other error propagates.
156
+ def run_script(script, sha, keys:, argv:)
157
+ with_redis do |redis|
158
+ redis.evalsha(sha, keys: keys, argv: argv)
159
+ rescue ::Redis::CommandError => e
160
+ raise unless e.message.include?('NOSCRIPT')
206
161
 
207
- def ensure_peek_token_bucket_script_loaded!
208
- @ensure_peek_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, PEEK_TOKEN_BUCKET_SCRIPT) }
162
+ redis.eval(script, keys: keys, argv: argv)
163
+ end
209
164
  end
210
165
 
211
166
  def validate_redis_connection!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThrottleMachines
4
- VERSION = '0.2.4'
4
+ VERSION = '0.3.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: throttle_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 8.0.4
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 8.0.4
25
+ version: '8.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: concurrent-ruby
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -71,42 +71,42 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '0.19'
74
+ version: '0.20'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '0.19'
81
+ version: '0.20'
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: chrono_machines
84
84
  requirement: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: '0.9'
88
+ version: '0.10'
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: '0.9'
95
+ version: '0.10'
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: minitest
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: '5.16'
102
+ version: '6.0'
103
103
  type: :development
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '5.16'
109
+ version: '6.0'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: rb_sys
112
112
  requirement: !ruby/object:Gem::Requirement