prorate 0.1.0 → 0.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
  SHA1:
3
- metadata.gz: d846e16d888b11395f59a44e8d487e9c3cccf336
4
- data.tar.gz: 16051a7c67452164d40c3d8c785a26b973eaf54e
3
+ metadata.gz: db453351faca0b61a4517795368fe719fe5c07bb
4
+ data.tar.gz: 165c6088be69a4a3b291059aa4e872061e5f999f
5
5
  SHA512:
6
- metadata.gz: b241b77bf6ec18bd394a0360bebd6b9e10b3b0cde21f1642860b0425d33b8b3a50dbaba9a5fe4fd7b5595dd95a566dfe122939a94711b651afec125e3ecfcc94
7
- data.tar.gz: 1f110ef412d5c231f1eed8550f3957c4fc9797eef42f3a92de802b2be1d15b06c450b1913706c036dd30924925acfce410ec29adf495539ea0dd08763f904d50
6
+ metadata.gz: b9dd96de6c8915e8ef39f7737e930976d4f83909b8eb861456966ede6a2d62cd82f0b40b634af8da824bbd303d84e83205924d6ceb51369a17e82e6eec01523f
7
+ data.tar.gz: 5e349bc7288a6da431d9ef7177fc77f2041638ace47fe319af1e533846472fe234e1760ccd3d26bb208ad3e649eb99ecbed7733dc14871f53f63e19dd4b512f7
@@ -1,5 +1,15 @@
1
- sudo: false
2
- language: ruby
3
1
  rvm:
4
- - 2.2.5
5
- before_install: gem install bundler -v 1.12.5
2
+ - 2.2.5
3
+ - 2.3.3
4
+ - 2.4.1
5
+
6
+ services:
7
+ - redis
8
+
9
+ dist: trusty # https://docs.travis-ci.com/user/trusty-ci-environment/
10
+ sudo: false
11
+ cache: bundler
12
+
13
+ # Travis permits the following phases: before_install, install, after_install, before_script, script, after_script
14
+ script:
15
+ - bundle exec rspec
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Prorate
2
2
 
3
3
  Provides a low-level time-based throttle. Is mainly meant for situations where using something like Rack::Attack is not very
4
- useful since you need access to more variables.
4
+ useful since you need access to more variables. Under the hood, this uses a Lua script that implements the
5
+ [Leaky Bucket](https://en.wikipedia.org/wiki/Leaky_bucket) algorithm in a single threaded and race condition safe way.
5
6
 
6
7
  ## Installation
7
8
 
@@ -23,7 +24,6 @@ Or install it yourself as:
23
24
 
24
25
  Within your Rails controller:
25
26
 
26
- throttle_args[:block_for] ||= throttle_args.fetch(:period)
27
27
  t = Prorate::Throttle.new(redis: Redis.new, logger: Rails.logger,
28
28
  name: "throttle-login-email", limit: 20, period: 5.seconds)
29
29
  # Add all the parameters that function as a discriminator
@@ -0,0 +1,50 @@
1
+ -- Single threaded Leaky Bucket implementation.
2
+ -- args: key_base, leak_rate, max_bucket_capacity, block_duration
3
+ -- returns: an array of two integers, the first of which indicates the remaining block time.
4
+ -- if the block time is nonzero, the second integer is always zero. If the block time is zero,
5
+ -- the second integer indicates the level of the bucket
6
+
7
+ -- this is required to be able to use TIME and writes; basically it lifts the script into IO
8
+ redis.replicate_commands()
9
+ -- make some nicer looking variable names:
10
+ local retval = nil
11
+ local bucket_level_key = ARGV[1] .. ".bucket_level"
12
+ local last_updated_key = ARGV[1] .. ".last_updated"
13
+ local block_key = ARGV[1] .. ".block"
14
+ local max_bucket_capacity = tonumber(ARGV[2])
15
+ local leak_rate = tonumber(ARGV[3])
16
+ local block_duration = tonumber(ARGV[4])
17
+ local now = tonumber(redis.call("TIME")[1]) --unix timestamp, will be required in all paths
18
+
19
+ local key_lifetime = math.ceil(max_bucket_capacity / leak_rate)
20
+
21
+ local blocked_until = redis.call("GET", block_key)
22
+ if blocked_until then
23
+ return {(tonumber(blocked_until) - now), 0}
24
+ end
25
+
26
+ -- get current bucket level
27
+ local bucket_level = tonumber(redis.call("GET", bucket_level_key))
28
+ if not bucket_level then
29
+ -- this throttle/identifier combo does not exist yet, so much calculation can be skipped
30
+ redis.call("SETEX", bucket_level_key, key_lifetime, 1) -- set bucket with initial value
31
+ retval = {0, 1}
32
+ else
33
+ -- if it already exists, do the leaky bucket thing
34
+ local last_updated = tonumber(redis.call("GET", last_updated_key)) or now -- use sensible default of 'now' if the key does not exist
35
+ local new_bucket_level = math.max(0, bucket_level - (leak_rate * (now - last_updated)))
36
+
37
+ if (new_bucket_level + 1) <= max_bucket_capacity then
38
+ new_bucket_level = new_bucket_level + 1
39
+ retval = {0, math.ceil(new_bucket_level)}
40
+ else
41
+ redis.call("SETEX", block_key, block_duration, now + block_duration)
42
+ retval = {block_duration, 0}
43
+ end
44
+ redis.call("SETEX", bucket_level_key, key_lifetime, new_bucket_level) --still needs to be saved
45
+ end
46
+
47
+ -- update last_updated for this bucket, required in all branches
48
+ redis.call("SETEX", last_updated_key, key_lifetime, now)
49
+
50
+ return retval
@@ -1,11 +1,36 @@
1
1
  require 'digest'
2
2
 
3
3
  module Prorate
4
+ class Throttled < StandardError
5
+ attr_reader :retry_in_seconds
6
+ def initialize(try_again_in)
7
+ @retry_in_seconds = try_again_in
8
+ super("Throttled, please lower your temper and try again in #{retry_in_seconds} seconds")
9
+ end
10
+ end
11
+
12
+ class ScriptHashMismatch < StandardError
13
+ end
14
+
15
+ class MisconfiguredThrottle < StandardError
16
+ end
17
+
4
18
  class Throttle < Ks.strict(:name, :limit, :period, :block_for, :redis, :logger)
19
+
20
+ def self.get_script_hash
21
+ script_filepath = File.join(__dir__,"rate_limit.lua")
22
+ script = File.read(script_filepath)
23
+ Digest::SHA1.hexdigest(script)
24
+ end
25
+
26
+ CURRENT_SCRIPT_HASH = get_script_hash
27
+
5
28
  def initialize(*)
6
29
  super
7
30
  @discriminators = [name.to_s]
8
31
  self.redis = NullPool.new(redis) unless redis.respond_to?(:with)
32
+ raise MisconfiguredThrottle if ((period <= 0) || (limit <= 0))
33
+ @leak_rate = limit.to_f / period # tokens per second;
9
34
  end
10
35
 
11
36
  def <<(discriminator)
@@ -17,17 +42,29 @@ module Prorate
17
42
  identifier = [name, discriminator].join(':')
18
43
 
19
44
  redis.with do |r|
20
- logger.info { "Checking throttle block %s" % name }
21
- raise Throttled.new(block_for) if Prorate::BlockFor.blocked?(id: identifier, redis: r)
22
-
23
45
  logger.info { "Applying throttle counter %s" % name }
24
- c = Prorate::Counter.new(redis: r, id: identifier, logger: logger, window_size: period)
25
- after_increment = c.incr
26
- if after_increment > limit
27
- logger.warn { "Throttle %s exceeded limit of %d at %d" % [name, limit, after_increment] }
28
- Prorate::BlockFor.block!(redis: r, id: identifier, duration: block_for)
29
- raise Throttled.new(period)
46
+ remaining_block_time, bucket_level = run_lua_throttler(redis: r, identifier: identifier, bucket_capacity: limit, leak_rate: @leak_rate, block_for: block_for)
47
+
48
+ if remaining_block_time > 0
49
+ logger.warn { "Throttle %s exceeded limit of %d in %d seconds and is blocked for the next %d seconds" % [name, limit, period, remaining_block_time] }
50
+ raise Throttled.new(remaining_block_time)
30
51
  end
52
+ available_calls = limit - bucket_level
53
+ end
54
+ end
55
+
56
+ def run_lua_throttler(redis: , identifier: , bucket_capacity: , leak_rate: , block_for: )
57
+ redis.evalsha(CURRENT_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for])
58
+ rescue Redis::CommandError => e
59
+ if e.message.include? "NOSCRIPT"
60
+ # The Redis server has never seen this script before. Needs to run only once in the entire lifetime of the Redis server (unless the script changes)
61
+ script_filepath = File.join(__dir__,"rate_limit.lua")
62
+ script = File.read(script_filepath)
63
+ raise ScriptHashMismatch if Digest::SHA1.hexdigest(script) != CURRENT_SCRIPT_HASH
64
+ redis.script(:load, script)
65
+ redis.evalsha(CURRENT_SCRIPT_HASH, [], [identifier, bucket_capacity, leak_rate, block_for])
66
+ else
67
+ raise e
31
68
  end
32
69
  end
33
70
  end
@@ -1,3 +1,3 @@
1
1
  module Prorate
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -0,0 +1,43 @@
1
+ # Runs a mild benchmark and prints out the average time a call to 'throttle!' takes.
2
+
3
+ require 'prorate'
4
+ require 'benchmark'
5
+ require 'redis'
6
+ require 'securerandom'
7
+
8
+ def average_ms(ary)
9
+ ary.map{|x| x*1000}.inject(0,&:+) / ary.length
10
+ end
11
+
12
+ r = Redis.new
13
+
14
+ logz = Logger.new(STDERR)
15
+ logz.level = Logger::FATAL # block out most stuff
16
+
17
+ times = []
18
+ 50.times do
19
+ times << Benchmark.realtime {
20
+ t = Prorate::Throttle.new(redis: r, logger: logz, name: "throttle-login-email", limit: 60, period: 30, block_for: 5)
21
+ # Add all the parameters that function as a discriminator
22
+ t << '127.0.2.1'
23
+ t << 'no_person@nowhere.com'
24
+ t.throttle!
25
+ }
26
+ end
27
+
28
+ puts average_ms times
29
+
30
+ times = []
31
+ 50.times do
32
+ email = SecureRandom.hex(20)
33
+ ip = SecureRandom.hex(10)
34
+ times << Benchmark.realtime {
35
+ t = Prorate::Throttle.new(redis: r, logger: logz, name: "throttle-login-email", limit: 30, period: 30, block_for: 5)
36
+ # Add all the parameters that function as a discriminator
37
+ t << ip
38
+ t << email
39
+ t.throttle!
40
+ }
41
+ end
42
+
43
+ puts average_ms times
@@ -0,0 +1,61 @@
1
+ # Runs a mild benchmark and prints out the average time a call to 'throttle!' takes.
2
+
3
+ require 'prorate'
4
+ require 'benchmark'
5
+ require 'redis'
6
+ require 'securerandom'
7
+
8
+ def average_ms(ary)
9
+ ary.map{|x| x*1000}.inject(0,&:+) / ary.length
10
+ end
11
+
12
+ r = Redis.new
13
+
14
+ # 4000000.times do
15
+ # random1 = SecureRandom.hex(10)
16
+ # random2 = SecureRandom.hex(10)
17
+ # r.set(random1,random2)
18
+ # end
19
+
20
+ logz = Logger.new(STDERR)
21
+ logz.level = Logger::FATAL # block out most stuff
22
+
23
+ times = []
24
+ 15.times do
25
+ id = SecureRandom.hex(10)
26
+ times << Benchmark.realtime {
27
+ r.evalsha('c95c5f1197cef04ec4afd7d64760f9175933e55a', [], [id, 120, 50, 10]) # values beyond 120 chosen more or less at random
28
+ }
29
+ end
30
+
31
+ puts average_ms times
32
+ def key_for_ts(ts)
33
+ "th:%s:%d" % [@id, ts]
34
+ end
35
+
36
+ times = []
37
+ 15.times do
38
+ id = SecureRandom.hex(10)
39
+ sec, _ = r.time # Use Redis time instead of the system timestamp, so that all the nodes are consistent
40
+ ts = sec.to_i # All Redis results are strings
41
+ k = key_for_ts(ts)
42
+ times << Benchmark.realtime {
43
+ r.multi do |txn|
44
+ # Increment the counter
45
+ txn.incr(k)
46
+ txn.expire(k, 120)
47
+
48
+ span_start = ts - 120
49
+ span_end = ts + 1
50
+ possible_keys = (span_start..span_end).map{|prev_time| key_for_ts(prev_time) }
51
+
52
+ # Fetch all the counter values within the time window. Despite the fact that this
53
+ # will return thousands of elements for large sliding window sizes, the values are
54
+ # small and an MGET in Redis is pretty cheap, so perf should stay well within limits.
55
+ txn.mget(*possible_keys)
56
+ end
57
+ }
58
+ end
59
+
60
+ puts average_ms times
61
+
@@ -0,0 +1,6 @@
1
+ # Reloads the script into redis and prints out the SHA it can be called with
2
+ require 'redis'
3
+ r = Redis.new
4
+ script = File.read('../lib/prorate/rate_limit.lua')
5
+ sha = r.script(:load,script)
6
+ puts sha
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prorate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-02-14 00:00:00.000000000 Z
11
+ date: 2017-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ks
@@ -111,14 +111,15 @@ files:
111
111
  - bin/console
112
112
  - bin/setup
113
113
  - lib/prorate.rb
114
- - lib/prorate/block_for.rb
115
- - lib/prorate/counter.rb
116
114
  - lib/prorate/null_logger.rb
117
115
  - lib/prorate/null_pool.rb
116
+ - lib/prorate/rate_limit.lua
118
117
  - lib/prorate/throttle.rb
119
- - lib/prorate/throttled.rb
120
118
  - lib/prorate/version.rb
121
119
  - prorate.gemspec
120
+ - scripts/bm.rb
121
+ - scripts/bm_latency_lb_vs_mget.rb
122
+ - scripts/reload_lua.rb
122
123
  homepage: https://github.com/WeTransfer/prorate
123
124
  licenses:
124
125
  - MIT
@@ -1,13 +0,0 @@
1
- module Prorate
2
- module BlockFor
3
- def self.block!(redis:, id:, duration:)
4
- k = "bl:%s" % id
5
- redis.setex(k, duration.to_i, 1)
6
- end
7
-
8
- def self.blocked?(redis:, id:)
9
- k = "bl:%s" % id
10
- !!redis.get(k)
11
- end
12
- end
13
- end
@@ -1,53 +0,0 @@
1
- module Prorate
2
- # The counter implements a rolling window throttling mechanism. At each call to incr(), the Redis time
3
- # is obtained. A counter then gets set at the key corresponding to the timestamp of the request, with a
4
- # granularity of a second. If requests are done continuously and in large volume, the counter will therefore
5
- # create one key for each second of the given rolling window size. he counters per second are set to auto-expire
6
- # after the window lapses. When incr() is performed, there is
7
- class Counter
8
- def initialize(redis:, logger: NullLogger, id:, window_size:)
9
- @redis = redis
10
- @logger = logger
11
- @id = id
12
- @in_span_of_seconds = window_size.to_i.abs
13
- end
14
-
15
- # Increments the throttle counter for this identifier, and returns the total number of requests
16
- # performed so far within the given time span. The caller can then determine whether the request has
17
- # to be throttled or can be let through.
18
- def incr
19
- sec, _ = @redis.time # Use Redis time instead of the system timestamp, so that all the nodes are consistent
20
- ts = sec.to_i # All Redis results are strings
21
- k = key_for_ts(ts)
22
- # Do the Redis stuff in a transaction, and capture only the necessary values
23
- # (the result of MULTI is all the return values of each call in sequence)
24
- *_, done_last_second, _, counter_values = @redis.multi do |txn|
25
- # Increment the counter
26
- txn.incr(k)
27
- txn.expire(k, @in_span_of_seconds)
28
-
29
- span_start = ts - @in_span_of_seconds
30
- span_end = ts + 1
31
- possible_keys = (span_start..span_end).map{|prev_time| key_for_ts(prev_time) }
32
- @logger.debug { "%s: Scanning %d possible keys" % [@id, possible_keys.length] }
33
-
34
- # Fetch all the counter values within the time window. Despite the fact that this
35
- # will return thousands of elements for large sliding window sizes, the values are
36
- # small and an MGET in Redis is pretty cheap, so perf should stay well within limits.
37
- txn.mget(*possible_keys)
38
- end
39
-
40
- # Sum all the values. The empty keys return nils from MGET, which become 0 on to_i casts.
41
- total_requests_during_period = counter_values.map(&:to_i).inject(&:+)
42
- @logger.debug { "%s: %d reqs total during the last %d seconds" % [@id, total_requests_during_period, @in_span_of_seconds] }
43
-
44
- total_requests_during_period
45
- end
46
-
47
- private
48
-
49
- def key_for_ts(ts)
50
- "th:%s:%d" % [@id, ts]
51
- end
52
- end
53
- end
@@ -1,9 +0,0 @@
1
- module Prorate
2
- class Throttled < StandardError
3
- attr_reader :retry_in_seconds
4
- def initialize(try_again_in)
5
- @retry_in_seconds = try_again_in
6
- super("Throttled, please lower your temper and try again in %d seconds" % try_again_in)
7
- end
8
- end
9
- end