pecorino 0.1.1 → 0.3.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: a58274f909ba72f93ce478129cd8cfb08893be9a6691a7a892b5e3be455fdd59
4
- data.tar.gz: 9bd363bc28d2095a3394abc36f0291c21b7b10e8652b8238243167a262f44740
3
+ metadata.gz: 2f94aa734cb0bb50657f5484bbdd8bfcaabc7c2d6b7d9329361d41456ba49db6
4
+ data.tar.gz: 97e01c53e828092ce60be1412a288a70446ec9cc5ab783a6fa6e3ba147de1ee5
5
5
  SHA512:
6
- metadata.gz: b9fbe0ec9ca780eb2e546b0f47bfcb0b4c579ac2f688dc59b7ebbcad39ebba875759fe62982c80b8f04e39553df6d1a64f7e8e9300d8f32b6d5f57c7c8a68405
7
- data.tar.gz: c909b8bb23045ef42eca9346f8956744aeb2f97d5c5446f62f5b16c808b56d99abbc291e5a40d2de31fa2027b4ed789567d51af98e44a23b4c0c2062cf7a71cd
6
+ metadata.gz: 4d83ebb84009492403ca8950d181f4689b42782ab3f65f7fe5091cab92fc4f739ecd64625449ab784309a122f09e62525c262c71f3c602d3d538f4ac511a78e3
7
+ data.tar.gz: 93d3a2845713c6dc71ff1f35e5433fcbca6837fdb336db00a7e403e5719f1e65eadac7dc819301c8886d40dc3f99c59b196d64004145de786d0875c42b98e635
@@ -0,0 +1,76 @@
1
+ name: CI
2
+
3
+ on:
4
+ - push
5
+ - pull_request
6
+
7
+ env:
8
+ BUNDLE_PATH: vendor/bundle
9
+
10
+ jobs:
11
+ # lint:
12
+ # name: Code Style
13
+ # runs-on: ubuntu-22.04
14
+ # if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
15
+ # strategy:
16
+ # matrix:
17
+ # ruby:
18
+ # - '2.7'
19
+ # steps:
20
+ # - name: Checkout
21
+ # uses: actions/checkout@v4
22
+ # - name: Setup Ruby
23
+ # uses: ruby/setup-ruby@v1
24
+ # with:
25
+ # ruby-version: ${{ matrix.ruby }}
26
+ # bundler-cache: true
27
+ # - name: Rubocop Cache
28
+ # uses: actions/cache@v3
29
+ # with:
30
+ # path: ~/.cache/rubocop_cache
31
+ # key: ${{ runner.os }}-rubocop-${{ hashFiles('.rubocop.yml') }}
32
+ # restore-keys: |
33
+ # ${{ runner.os }}-rubocop-
34
+ # - name: Rubocop
35
+ # run: bundle exec rubocop
36
+ test:
37
+ name: Tests
38
+ runs-on: ubuntu-22.04
39
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
40
+ strategy:
41
+ matrix:
42
+ ruby:
43
+ # - '2.6'
44
+ - '3.2'
45
+ services:
46
+ # mysql:
47
+ # image: mysql:5.7
48
+ # env:
49
+ # MYSQL_ALLOW_EMPTY_PASSWORD: yes
50
+ # ports:
51
+ # - 3306:3306
52
+ # options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
53
+ postgres:
54
+ image: postgres
55
+ env:
56
+ POSTGRES_PASSWORD: postgres
57
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
58
+ ports:
59
+ - 5432:5432
60
+ steps:
61
+ - name: Checkout
62
+ uses: actions/checkout@v4
63
+ - name: Setup Ruby
64
+ uses: ruby/setup-ruby@v1
65
+ with:
66
+ ruby-version: ${{ matrix.ruby }}
67
+ bundler-cache: true
68
+ - name: "Tests and Lint"
69
+ run: bundle exec rake
70
+ env:
71
+ PGHOST: localhost
72
+ PGUSER: postgres
73
+ PGPASSWORD: postgres
74
+ TESTOPTS: "--fail-fast"
75
+ # MYSQL_HOST: 127.0.0.1
76
+ # MYSQL_PORT: 3306
data/CHANGELOG.md CHANGED
@@ -1,4 +1,13 @@
1
- ## [Unreleased]
1
+ ## [0.3.0] - 2024-01-18
2
+
3
+ - Allow `over_time` in addition to `leak_rate`, which is a more intuitive parameter to tweak
4
+ - Set default `block_for` to the time it takes the bucket to leak out completely instead of 30 seconds
5
+
6
+ ## [0.2.0] - 2024-01-09
7
+
8
+ - [Add support for SQLite](https://github.com/cheddar-me/pecorino/pull/9)
9
+ - [Use comparisons in SQL to determine whether the leaky bucket did overflow](https://github.com/cheddar-me/pecorino/pull/8)
10
+ - [Change the way Structs are defined to appease Tapioca/Sorbet](https://github.com/cheddar-me/pecorino/pull/6)
2
11
 
3
12
  ## [0.1.0] - 2023-10-30
4
13
 
data/Gemfile CHANGED
@@ -4,7 +4,3 @@ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in pecorino.gemspec
6
6
  gemspec
7
-
8
- gem "rake", "~> 13.0"
9
-
10
- gem "minitest", "~> 5.0"
data/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Pecorino is a rate limiter based on the concept of leaky buckets. It uses your DB as the storage backend for the throttles. It is compact, easy to install, and does not require additional infrastructure. The approach used by Pecorino has been previously used by [prorate](https://github.com/WeTransfer/prorate) with Redis, and that approach has proven itself.
4
4
 
5
- Pecorino is designed to integrate seamlessly into any Rails application using a Postgres database (at the moment there is no MySQL support, we would be delighted if you could add it).
5
+ Pecorino is designed to integrate seamlessly into any Rails application using a PostgreSQL or SQLite database (at the moment there is no MySQL support, we would be delighted if you could add it).
6
6
 
7
- If you would like to know more about the leaky bucket algorithm: the [Wikipedia article](https://en.wikipedia.org/wiki/Leaky_bucket) is a great starting point.
7
+ If you would like to know more about the leaky bucket algorithm: [this article](http://live.julik.nl/2022/08/the-unreasonable-effectiveness-of-leaky-buckets) or the [Wikipedia article](https://en.wikipedia.org/wiki/Leaky_bucket) are both good starting points.
8
8
 
9
9
  ## Installation
10
10
 
@@ -17,23 +17,34 @@ gem 'pecorino'
17
17
  And then execute:
18
18
 
19
19
  $ bundle install
20
-
21
- Or install it yourself as:
22
-
23
- $ gem install pecorino
20
+ $ bin/rails g pecorino:install
21
+ $ bin/rails db:migrate
24
22
 
25
23
  ## Usage
26
24
 
27
- First, add and run the migration to create the pecorino tables:
25
+ Once the installation is done you can use Pecorino to start defining your throttles. Imagine you have a resource called `vault` and you want to limit the number of updates to it to 5 per second. To achieve that, instantiate a new `Throttle` in your controller or job code, and then trigger it using `Throttle#request!`. A call to `request!` registers 1 token getting added to the bucket. If the bucket is full, or the throttle is currently in "block" mode (has recently been triggered), a `Pecorino::Throttle::Throttled` exception will be raised.
28
26
 
29
- $ bin/rails g pecorino:install
30
- $ bin/rails db:migrate
27
+ ```ruby
28
+ throttle = Pecorino::Throttle.new(key: "vault", over_time: 1.second, capacity: 5)
29
+ throttle.request!
30
+ ```
31
+ In a Rails controller you can then rescue from this exception to render the appropriate response:
32
+
33
+ ```ruby
34
+ rescue_from Pecorino::Throttle::Throttled do |e|
35
+ response.set_header('Retry-After', e.retry_after.to_s)
36
+ render nothing: true, status: 429
37
+ end
38
+ ```
31
39
 
32
- Once that is done, you can use Pecorino to start defining your throttles. Imagine you have a resource called `vault` and you want to limit the number of updates to it to 5 per second. To achieve that, instantiate a new `Throttle` in your controller or job code, and then trigger it using `Throttle#request!`. A call to `request!` registers 1 token getting added to the bucket. If the bucket is full, or the throttle is currently in "block" mode (has recently been triggered), a `Pecorino::Throttle::Throttled` exception will be raised.
40
+ and in a Rack application you can rescue inline:
33
41
 
34
42
  ```ruby
35
- throttle = Pecorino::Throttle.new(key: "vault", leak_rate: 5, capacity: 5)
36
- throttle.request!
43
+ def call(env)
44
+ # ...your code
45
+ rescue Pecorino::Throttle::Throttled => e
46
+ [429, {"Retry-After" => e.retry_after.to_s}, []]
47
+ end
37
48
  ```
38
49
 
39
50
  The exception has an attribute called `retry_after` which you can use to render the appropriate 429 response.
@@ -47,7 +58,7 @@ return render :capacity_exceeded unless throttle.able_to_accept?
47
58
  If you are dealing with a metered resource (like throughput, money, amount of storage...) you can supply the number of tokens to either `request!` or `able_to_accept?` to indicate the desired top-up of the leaky bucket. For example, if you are maintaining user wallets and want to ensure no more than 100 dollars may be taken from the wallet within a certain amount of time, you can do it like so:
48
59
 
49
60
  ```ruby
50
- throttle = Pecorino::Throttle.new(key: "wallet_t_#{current_user.id}", leak_rate: 100 / 60.0 / 60.0, capacity: 100, block_for: 60*60*3)
61
+ throttle = Pecorino::Throttle.new(key: "wallet_t_#{current_user.id}", over_time_: 1.hour, capacity: 100, block_for: 60*60*3)
51
62
  throttle.request!(20) # Attempt to withdraw 20 dollars
52
63
  throttle.request!(20) # Attempt to withdraw 20 dollars more
53
64
  throttle.request!(20) # Attempt to withdraw 20 dollars more
@@ -72,9 +83,11 @@ Check out the inline YARD documentation for more options.
72
83
 
73
84
  We recommend running the following bit of code every couple of hours (via cron or similar) to delete the stale blocks and leaky buckets from the system:
74
85
 
75
- Pecorino.prune!
86
+ ```ruby
87
+ Pecorino.prune!
88
+ ```
76
89
 
77
- ## Using unlogged tables for reduced replication load
90
+ ## Using unlogged tables for reduced replication load (PostgreSQL)
78
91
 
79
92
  Throttles and leaky buckets are transient resources. If you are using Postgres replication, it might be prudent to set the Pecorino tables to `UNLOGGED` which will exclude them from replication - and save you bandwidth and storage on your RR. To do so, add the following statements to your migration:
80
93
 
data/Rakefile CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
+ require "standard/rake"
5
6
 
6
7
  Rake::TestTask.new(:test) do |t|
7
8
  t.libs << "test"
@@ -9,4 +10,9 @@ Rake::TestTask.new(:test) do |t|
9
10
  t.test_files = FileList["test/**/*_test.rb"]
10
11
  end
11
12
 
12
- task default: :test
13
+ task :format do
14
+ `bundle exec standardrb --fix`
15
+ `bundle exec magic_frozen_string_literal .`
16
+ end
17
+
18
+ task default: [:test, :standard]
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
- require 'rails/generators'
3
- require 'rails/generators/active_record'
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
4
5
 
5
6
  module Pecorino
6
7
  #
@@ -13,11 +14,11 @@ module Pecorino
13
14
  TEMPLATES = File.join(File.dirname(__FILE__))
14
15
  source_paths << TEMPLATES
15
16
 
16
- class_option :database, type: :string, aliases: %i(--db), desc: "The database for your migration. By default, the current environment's primary database is used."
17
+ class_option :database, type: :string, aliases: %i[--db], desc: "The database for your migration. By default, the current environment's primary database is used."
17
18
 
18
19
  # Generates monolithic migration file that contains all database changes.
19
20
  def create_migration_file
20
- migration_template 'migrations/create_pecorino_tables.rb.erb', File.join(db_migrate_path, "create_pecorino_tables.rb")
21
+ migration_template "migrations/create_pecorino_tables.rb.erb", File.join(db_migrate_path, "create_pecorino_tables.rb")
21
22
  end
22
23
 
23
24
  private
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # This offers just the leaky bucket implementation with fill control, but without the timed lock.
4
- # It does not raise any exceptions, it just tracks the state of a leaky bucket in Postgres.
4
+ # It does not raise any exceptions, it just tracks the state of a leaky bucket in the database.
5
5
  #
6
6
  # Leak rate is specified directly in tokens per second, instead of specifying the block period.
7
7
  # The bucket level is stored and returned as a Float which allows for finer-grained measurement,
@@ -25,7 +25,7 @@
25
25
  # The storage use is one DB row per leaky bucket you need to manage (likely - one throttled entity such
26
26
  # as a combination of an IP address + the URL you need to procect). The `key` is an arbitrary string you provide.
27
27
  class Pecorino::LeakyBucket
28
- class State < Struct.new(:level, :full)
28
+ State = Struct.new(:level, :full) do
29
29
  # Returns the level of the bucket after the operation on the LeakyBucket
30
30
  # object has taken place. There is a guarantee that no tokens have leaked
31
31
  # from the bucket between the operation and the freezing of the State
@@ -59,21 +59,40 @@ class Pecorino::LeakyBucket
59
59
  end
60
60
  end
61
61
 
62
- # Creates a new LeakyBucket. The object controls 1 row in Postgres which is
62
+ # The key (name) of the leaky bucket
63
+ # @return [String]
64
+ attr_reader :key
65
+
66
+ # The leak rate (tokens per second) of the bucket
67
+ # @return [Float]
68
+ attr_reader :leak_rate
69
+
70
+ # The capacity of the bucket in tokens
71
+ # @return [Float]
72
+ attr_reader :capacity
73
+
74
+ # Creates a new LeakyBucket. The object controls 1 row in the database is
63
75
  # specific to the bucket key.
64
76
  #
65
77
  # @param key[String] the key for the bucket. The key also gets used
66
78
  # to derive locking keys, so that operations on a particular bucket
67
79
  # are always serialized.
68
- # @param leak_rate[Float] the leak rate of the bucket, in tokens per second
80
+ # @param leak_rate[Float] the leak rate of the bucket, in tokens per second.
81
+ # Either `leak_rate` or `over_time` can be used, but not both.
82
+ # @param over_time[#to_f] over how many seconds the bucket will leak out to 0 tokens.
83
+ # The value is assumed to be the number of seconds
84
+ # - or a duration which returns the number of seconds from `to_f`.
85
+ # Either `leak_rate` or `over_time` can be used, but not both.
69
86
  # @param capacity[Numeric] how many tokens is the bucket capped at.
70
87
  # Filling up the bucket using `fillup()` will add to that number, but
71
88
  # the bucket contents will then be capped at this value. So with
72
89
  # bucket_capacity set to 12 and a `fillup(14)` the bucket will reach the level
73
90
  # of 12, and will then immediately start leaking again.
74
- def initialize(key:, leak_rate:, capacity:)
91
+ def initialize(key:, capacity:, leak_rate: nil, over_time: nil)
92
+ raise ArgumentError, "Either leak_rate: or over_time: must be specified" if leak_rate.nil? && over_time.nil?
93
+ raise ArgumentError, "Either leak_rate: or over_time: may be specified, but not both" if leak_rate && over_time
94
+ @leak_rate = leak_rate || (over_time.to_f / capacity)
75
95
  @key = key
76
- @leak_rate = leak_rate.to_f
77
96
  @capacity = capacity.to_f
78
97
  end
79
98
 
@@ -86,7 +105,8 @@ class Pecorino::LeakyBucket
86
105
  # @param n_tokens[Float]
87
106
  # @return [State] the state of the bucket after the operation
88
107
  def fillup(n_tokens)
89
- add_tokens(n_tokens.to_f)
108
+ capped_level_after_fillup, did_overflow = Pecorino.adapter.add_tokens(capacity: @capacity, key: @key, leak_rate: @leak_rate, n_tokens: n_tokens)
109
+ State.new(capped_level_after_fillup, did_overflow)
90
110
  end
91
111
 
92
112
  # Returns the current state of the bucket, containing the level and whether the bucket is full.
@@ -94,34 +114,8 @@ class Pecorino::LeakyBucket
94
114
  #
95
115
  # @return [State] the snapshotted state of the bucket at time of query
96
116
  def state
97
- conn = ActiveRecord::Base.connection
98
- query_params = {
99
- key: @key,
100
- capa: @capacity.to_f,
101
- leak_rate: @leak_rate.to_f
102
- }
103
- # The `level` of the bucket is what got stored at `last_touched_at` time, and we can
104
- # extrapolate from it to see how many tokens have leaked out since `last_touched_at` -
105
- # we don't need to UPDATE the value in the bucket here
106
- sql = ActiveRecord::Base.sanitize_sql_array([<<~SQL, query_params])
107
- SELECT
108
- GREATEST(
109
- 0.0, LEAST(
110
- :capa,
111
- t.level - (EXTRACT(EPOCH FROM (clock_timestamp() - t.last_touched_at)) * :leak_rate)
112
- )
113
- )
114
- FROM
115
- pecorino_leaky_buckets AS t
116
- WHERE
117
- key = :key
118
- SQL
119
-
120
- # If the return value of the query is a NULL it means no such bucket exists,
121
- # so we assume the bucket is empty
122
- current_level = conn.uncached { conn.select_value(sql) } || 0.0
123
-
124
- State.new(current_level, (@capacity - current_level).abs < 0.01)
117
+ current_level, is_full = Pecorino.adapter.state(key: @key, capacity: @capacity, leak_rate: @leak_rate)
118
+ State.new(current_level, is_full)
125
119
  end
126
120
 
127
121
  # Tells whether the bucket can accept the amount of tokens without overflowing.
@@ -135,58 +129,4 @@ class Pecorino::LeakyBucket
135
129
  def able_to_accept?(n_tokens)
136
130
  (state.level + n_tokens) < @capacity
137
131
  end
138
-
139
- private
140
-
141
- def add_tokens(n_tokens)
142
- conn = ActiveRecord::Base.connection
143
-
144
- # Take double the time it takes the bucket to empty under normal circumstances
145
- # until the bucket may be deleted.
146
- may_be_deleted_after_seconds = (@capacity.to_f / @leak_rate.to_f) * 2.0
147
-
148
- # Create the leaky bucket if it does not exist, and update
149
- # to the new level, taking the leak rate into account - if the bucket exists.
150
- query_params = {
151
- key: @key,
152
- capa: @capacity.to_f,
153
- delete_after_s: may_be_deleted_after_seconds,
154
- leak_rate: @leak_rate.to_f,
155
- fillup: n_tokens.to_f
156
- }
157
- sql = ActiveRecord::Base.sanitize_sql_array([<<~SQL, query_params])
158
- INSERT INTO pecorino_leaky_buckets AS t
159
- (key, last_touched_at, may_be_deleted_after, level)
160
- VALUES
161
- (
162
- :key,
163
- clock_timestamp(),
164
- clock_timestamp() + ':delete_after_s second'::interval,
165
- GREATEST(0.0,
166
- LEAST(
167
- :capa,
168
- :fillup
169
- )
170
- )
171
- )
172
- ON CONFLICT (key) DO UPDATE SET
173
- last_touched_at = EXCLUDED.last_touched_at,
174
- may_be_deleted_after = EXCLUDED.may_be_deleted_after,
175
- level = GREATEST(0.0,
176
- LEAST(
177
- :capa,
178
- t.level + :fillup - (EXTRACT(EPOCH FROM (EXCLUDED.last_touched_at - t.last_touched_at)) * :leak_rate)
179
- )
180
- )
181
- RETURNING level
182
- SQL
183
-
184
- # Note the use of .uncached here. The AR query cache will actually see our
185
- # query as a repeat (since we use "select_value" for the RETURNING bit) and will not call into Postgres
186
- # correctly, thus the clock_timestamp() value would be frozen between calls. We don't want that here.
187
- # See https://stackoverflow.com/questions/73184531/why-would-postgres-clock-timestamp-freeze-inside-a-rails-unit-test
188
- level_after_fillup = conn.uncached { conn.select_value(sql) }
189
-
190
- State.new(level_after_fillup, (@capacity - level_after_fillup).abs < 0.01)
191
- end
192
132
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ Pecorino::Postgres = Struct.new(:model_class) do
4
+ def state(key:, capacity:, leak_rate:)
5
+ query_params = {
6
+ key: key.to_s,
7
+ capacity: capacity.to_f,
8
+ leak_rate: leak_rate.to_f
9
+ }
10
+ # The `level` of the bucket is what got stored at `last_touched_at` time, and we can
11
+ # extrapolate from it to see how many tokens have leaked out since `last_touched_at` -
12
+ # we don't need to UPDATE the value in the bucket here
13
+ sql = model_class.sanitize_sql_array([<<~SQL, query_params])
14
+ SELECT
15
+ GREATEST(
16
+ 0.0, LEAST(
17
+ :capacity,
18
+ t.level - (EXTRACT(EPOCH FROM (clock_timestamp() - t.last_touched_at)) * :leak_rate)
19
+ )
20
+ )
21
+ FROM
22
+ pecorino_leaky_buckets AS t
23
+ WHERE
24
+ key = :key
25
+ SQL
26
+
27
+ # If the return value of the query is a NULL it means no such bucket exists,
28
+ # so we assume the bucket is empty
29
+ current_level = model_class.connection.uncached { model_class.connection.select_value(sql) } || 0.0
30
+ [current_level, capacity - current_level.abs < 0.01]
31
+ end
32
+
33
+ def add_tokens(key:, capacity:, leak_rate:, n_tokens:)
34
+ # Take double the time it takes the bucket to empty under normal circumstances
35
+ # until the bucket may be deleted.
36
+ may_be_deleted_after_seconds = (capacity.to_f / leak_rate.to_f) * 2.0
37
+
38
+ # Create the leaky bucket if it does not exist, and update
39
+ # to the new level, taking the leak rate into account - if the bucket exists.
40
+ query_params = {
41
+ key: key.to_s,
42
+ capacity: capacity.to_f,
43
+ delete_after_s: may_be_deleted_after_seconds,
44
+ leak_rate: leak_rate.to_f,
45
+ fillup: n_tokens.to_f
46
+ }
47
+
48
+ sql = model_class.sanitize_sql_array([<<~SQL, query_params])
49
+ INSERT INTO pecorino_leaky_buckets AS t
50
+ (key, last_touched_at, may_be_deleted_after, level)
51
+ VALUES
52
+ (
53
+ :key,
54
+ clock_timestamp(),
55
+ clock_timestamp() + ':delete_after_s second'::interval,
56
+ GREATEST(0.0,
57
+ LEAST(
58
+ :capacity,
59
+ :fillup
60
+ )
61
+ )
62
+ )
63
+ ON CONFLICT (key) DO UPDATE SET
64
+ last_touched_at = EXCLUDED.last_touched_at,
65
+ may_be_deleted_after = EXCLUDED.may_be_deleted_after,
66
+ level = GREATEST(0.0,
67
+ LEAST(
68
+ :capacity,
69
+ t.level + :fillup - (EXTRACT(EPOCH FROM (EXCLUDED.last_touched_at - t.last_touched_at)) * :leak_rate)
70
+ )
71
+ )
72
+ RETURNING
73
+ level,
74
+ -- Compare level to the capacity inside the DB so that we won't have rounding issues
75
+ level >= :capacity AS did_overflow
76
+ SQL
77
+
78
+ # Note the use of .uncached here. The AR query cache will actually see our
79
+ # query as a repeat (since we use "select_one" for the RETURNING bit) and will not call into Postgres
80
+ # correctly, thus the clock_timestamp() value would be frozen between calls. We don't want that here.
81
+ # See https://stackoverflow.com/questions/73184531/why-would-postgres-clock-timestamp-freeze-inside-a-rails-unit-test
82
+ upserted = model_class.connection.uncached { model_class.connection.select_one(sql) }
83
+ capped_level_after_fillup, did_overflow = upserted.fetch("level"), upserted.fetch("did_overflow")
84
+ [capped_level_after_fillup, did_overflow]
85
+ end
86
+
87
+ def set_block(key:, block_for:)
88
+ query_params = {key: key.to_s, block_for: block_for.to_f}
89
+ block_set_query = model_class.sanitize_sql_array([<<~SQL, query_params])
90
+ INSERT INTO pecorino_blocks AS t
91
+ (key, blocked_until)
92
+ VALUES
93
+ (:key, NOW() + ':block_for seconds'::interval)
94
+ ON CONFLICT (key) DO UPDATE SET
95
+ blocked_until = GREATEST(EXCLUDED.blocked_until, t.blocked_until)
96
+ RETURNING blocked_until;
97
+ SQL
98
+ model_class.connection.uncached { model_class.connection.select_value(block_set_query) }
99
+ end
100
+
101
+ def blocked_until(key:)
102
+ block_check_query = model_class.sanitize_sql_array([<<~SQL, key])
103
+ SELECT blocked_until FROM pecorino_blocks WHERE key = ? AND blocked_until >= NOW() LIMIT 1
104
+ SQL
105
+ model_class.connection.uncached { model_class.connection.select_value(block_check_query) }
106
+ end
107
+ end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pecorino
2
4
  class Railtie < Rails::Railtie
3
5
  generators do
4
6
  require_relative "install_generator"
5
7
  end
6
8
  end
7
- end
9
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ Pecorino::Sqlite = Struct.new(:model_class) do
4
+ def state(key:, capacity:, leak_rate:)
5
+ # With a server database, it is really important to use the clock of the database itself so
6
+ # that concurrent requests will see consistent bucket level calculations. Since SQLite is
7
+ # actually in-process, there is no point using DB functions - and besides, SQLite reduces
8
+ # the time precision to the nearest millisecond - and the calculations with timestamps are
9
+ # obtuse. Therefore we can use the current time inside the Ruby VM - it doesn't matter all that
10
+ # much but saves us on writing some gnarly SQL to have SQLite produce consistent precise timestamps.
11
+ query_params = {
12
+ key: key.to_s,
13
+ capacity: capacity.to_f,
14
+ leak_rate: leak_rate.to_f,
15
+ now_s: Time.now.to_f
16
+ }
17
+ # The `level` of the bucket is what got stored at `last_touched_at` time, and we can
18
+ # extrapolate from it to see how many tokens have leaked out since `last_touched_at` -
19
+ # we don't need to UPDATE the value in the bucket here
20
+ sql = model_class.sanitize_sql_array([<<~SQL, query_params])
21
+ SELECT
22
+ MAX(
23
+ 0.0, MIN(
24
+ :capacity,
25
+ t.level - ((:now_s - t.last_touched_at) * :leak_rate)
26
+ )
27
+ )
28
+ FROM
29
+ pecorino_leaky_buckets AS t
30
+ WHERE
31
+ key = :key
32
+ SQL
33
+
34
+ # If the return value of the query is a NULL it means no such bucket exists,
35
+ # so we assume the bucket is empty
36
+ current_level = model_class.connection.uncached { model_class.connection.select_value(sql) } || 0.0
37
+ [current_level, capacity - current_level.abs < 0.01]
38
+ end
39
+
40
+ def add_tokens(key:, capacity:, leak_rate:, n_tokens:)
41
+ # Take double the time it takes the bucket to empty under normal circumstances
42
+ # until the bucket may be deleted.
43
+ may_be_deleted_after_seconds = (capacity.to_f / leak_rate.to_f) * 2.0
44
+
45
+ # Create the leaky bucket if it does not exist, and update
46
+ # to the new level, taking the leak rate into account - if the bucket exists.
47
+ query_params = {
48
+ key: key.to_s,
49
+ capacity: capacity.to_f,
50
+ delete_after_s: may_be_deleted_after_seconds,
51
+ leak_rate: leak_rate.to_f,
52
+ now_s: Time.now.to_f, # See above as to why we are using a time value passed in
53
+ fillup: n_tokens.to_f,
54
+ id: SecureRandom.uuid # SQLite3 does not autogenerate UUIDs
55
+ }
56
+
57
+ sql = model_class.sanitize_sql_array([<<~SQL, query_params])
58
+ INSERT INTO pecorino_leaky_buckets AS t
59
+ (id, key, last_touched_at, may_be_deleted_after, level)
60
+ VALUES
61
+ (
62
+ :id,
63
+ :key,
64
+ :now_s, -- Precision loss must be avoided here as it is used for calculations
65
+ DATETIME('now', '+:delete_after_s seconds'), -- Precision loss is acceptable here
66
+ MAX(0.0,
67
+ MIN(
68
+ :capacity,
69
+ :fillup
70
+ )
71
+ )
72
+ )
73
+ ON CONFLICT (key) DO UPDATE SET
74
+ last_touched_at = EXCLUDED.last_touched_at,
75
+ may_be_deleted_after = EXCLUDED.may_be_deleted_after,
76
+ level = MAX(0.0,
77
+ MIN(
78
+ :capacity,
79
+ t.level + :fillup - ((:now_s - t.last_touched_at) * :leak_rate)
80
+ )
81
+ )
82
+ RETURNING
83
+ level,
84
+ -- Compare level to the capacity inside the DB so that we won't have rounding issues
85
+ level >= :capacity AS did_overflow
86
+ SQL
87
+
88
+ # Note the use of .uncached here. The AR query cache will actually see our
89
+ # query as a repeat (since we use "select_one" for the RETURNING bit) and will not call into Postgres
90
+ # correctly, thus the clock_timestamp() value would be frozen between calls. We don't want that here.
91
+ # See https://stackoverflow.com/questions/73184531/why-would-postgres-clock-timestamp-freeze-inside-a-rails-unit-test
92
+ upserted = model_class.connection.uncached { model_class.connection.select_one(sql) }
93
+ capped_level_after_fillup, one_if_did_overflow = upserted.fetch("level"), upserted.fetch("did_overflow")
94
+ [capped_level_after_fillup, one_if_did_overflow == 1]
95
+ end
96
+
97
+ def set_block(key:, block_for:)
98
+ query_params = {id: SecureRandom.uuid, key: key.to_s, block_for: block_for.to_f, now_s: Time.now.to_f}
99
+ block_set_query = model_class.sanitize_sql_array([<<~SQL, query_params])
100
+ INSERT INTO pecorino_blocks AS t
101
+ (id, key, blocked_until)
102
+ VALUES
103
+ (:id, :key, :now_s + :block_for)
104
+ ON CONFLICT (key) DO UPDATE SET
105
+ blocked_until = MAX(EXCLUDED.blocked_until, t.blocked_until)
106
+ RETURNING blocked_until;
107
+ SQL
108
+ blocked_until_s = model_class.connection.uncached { model_class.connection.select_value(block_set_query) }
109
+ Time.at(blocked_until_s)
110
+ end
111
+
112
+ def blocked_until(key:)
113
+ now_s = Time.now.to_f
114
+ block_check_query = model_class.sanitize_sql_array([<<~SQL, {now_s: now_s, key: key}])
115
+ SELECT
116
+ blocked_until
117
+ FROM
118
+ pecorino_blocks
119
+ WHERE
120
+ key = :key AND blocked_until >= :now_s LIMIT 1
121
+ SQL
122
+ blocked_until_s = model_class.connection.uncached { model_class.connection.select_value(block_check_query) }
123
+ blocked_until_s && Time.at(blocked_until_s)
124
+ end
125
+ end
@@ -6,7 +6,7 @@
6
6
  # the block is lifted. The block time can be arbitrarily higher or lower than the amount
7
7
  # of time it takes for the leaky bucket to leak out
8
8
  class Pecorino::Throttle
9
- class State < Struct.new(:blocked_until)
9
+ State = Struct.new(:blocked_until) do
10
10
  # Tells whether this throttle is blocked, either due to the leaky bucket having filled up
11
11
  # or due to there being a timed block set because of an earlier event of the bucket having
12
12
  # filled up
@@ -43,13 +43,14 @@ class Pecorino::Throttle
43
43
  end
44
44
 
45
45
  # @param key[String] the key for both the block record and the leaky bucket
46
- # @param block_for[Numeric] the number of seconds to block any further requests for
46
+ # @param block_for[Numeric] the number of seconds to block any further requests for. Defaults to time it takes
47
+ # the bucket to leak out to the level of 0
47
48
  # @param leaky_bucket_options Options for `Pecorino::LeakyBucket.new`
48
49
  # @see PecorinoLeakyBucket.new
49
- def initialize(key:, block_for: 30, **leaky_bucket_options)
50
+ def initialize(key:, block_for: nil, **)
51
+ @bucket = Pecorino::LeakyBucket.new(key:, **)
50
52
  @key = key.to_s
51
- @block_for = block_for.to_f
52
- @bucket = Pecorino::LeakyBucket.new(key:, **leaky_bucket_options)
53
+ @block_for = block_for ? block_for.to_f : (@bucket.capacity / @bucket.leak_rate)
53
54
  end
54
55
 
55
56
  # Tells whether the throttle will let this number of requests pass without raising
@@ -60,8 +61,7 @@ class Pecorino::Throttle
60
61
  # @param n_tokens[Float]
61
62
  # @return [boolean]
62
63
  def able_to_accept?(n_tokens = 1)
63
- conn = ActiveRecord::Base.connection
64
- !blocked_until(conn) && @bucket.able_to_accept?(n_tokens)
64
+ Pecorino.adapter.blocked_until(key: @key).nil? && @bucket.able_to_accept?(n_tokens)
65
65
  end
66
66
 
67
67
  # Register that a request is being performed. Will raise Throttled
@@ -98,35 +98,14 @@ class Pecorino::Throttle
98
98
  #
99
99
  # @return [State] the state of the throttle after filling up the leaky bucket / trying to pass the block
100
100
  def request(n = 1)
101
- conn = ActiveRecord::Base.connection
102
- existing_blocked_until = blocked_until(conn)
101
+ existing_blocked_until = Pecorino.adapter.blocked_until(key: @key)
103
102
  return State.new(existing_blocked_until.utc) if existing_blocked_until
104
103
 
105
104
  # Topup the leaky bucket
106
105
  return State.new(nil) unless @bucket.fillup(n.to_f).full?
107
106
 
108
107
  # and set the block if we reached it
109
- query_params = {key: @key, block_for: @block_for}
110
- block_set_query = ActiveRecord::Base.sanitize_sql_array([<<~SQL, query_params])
111
- INSERT INTO pecorino_blocks AS t
112
- (key, blocked_until)
113
- VALUES
114
- (:key, NOW() + ':block_for seconds'::interval)
115
- ON CONFLICT (key) DO UPDATE SET
116
- blocked_until = GREATEST(EXCLUDED.blocked_until, t.blocked_until)
117
- RETURNING blocked_until;
118
- SQL
119
-
120
- fresh_blocked_until = conn.uncached { conn.select_value(block_set_query) }
108
+ fresh_blocked_until = Pecorino.adapter.set_block(key: @key, block_for: @block_for)
121
109
  State.new(fresh_blocked_until.utc)
122
110
  end
123
-
124
- private
125
-
126
- def blocked_until(via_connection)
127
- block_check_query = ActiveRecord::Base.sanitize_sql_array([<<~SQL, @key])
128
- SELECT blocked_until FROM pecorino_blocks WHERE key = ? AND blocked_until >= NOW() LIMIT 1
129
- SQL
130
- via_connection.uncached { via_connection.select_value(block_check_query) }
131
- end
132
111
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pecorino
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/pecorino.rb CHANGED
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/concern"
4
+ require "active_record/sanitization"
5
+
3
6
  require_relative "pecorino/version"
4
7
  require_relative "pecorino/leaky_bucket"
5
8
  require_relative "pecorino/throttle"
6
9
  require_relative "pecorino/railtie" if defined?(Rails::Railtie)
7
10
 
8
11
  module Pecorino
12
+ autoload :Postgres, "pecorino/postgres"
13
+ autoload :Sqlite, "pecorino/sqlite"
14
+
9
15
  # Deletes stale leaky buckets and blocks which have expired. Run this method regularly to
10
16
  # avoid accumulating too many unused rows in your tables.
11
17
  #
@@ -19,12 +25,12 @@ module Pecorino
19
25
  ActiveRecord::Base.connection.execute("DELETE FROM pecorino_leaky_buckets WHERE may_be_deleted_after < NOW()")
20
26
  end
21
27
 
22
-
23
28
  # Creates the tables and indexes needed for Pecorino. Call this from your migrations like so:
24
- # class CreatePecorinoTables < ActiveRecord::Migration<%= migration_version %>
25
29
  #
26
- # def change
27
- # Pecorino.create_tables(self)
30
+ # class CreatePecorinoTables < ActiveRecord::Migration[7.0]
31
+ # def change
32
+ # Pecorino.create_tables(self)
33
+ # end
28
34
  # end
29
35
  #
30
36
  # @param active_record_schema[ActiveRecord::SchemaMigration] the migration through which we will create the tables
@@ -46,4 +52,20 @@ module Pecorino
46
52
  active_record_schema.add_index :pecorino_blocks, [:key], unique: true
47
53
  active_record_schema.add_index :pecorino_blocks, [:blocked_until]
48
54
  end
55
+
56
+ # Returns the database implementation for setting the values atomically. Since the implementation
57
+ # differs per database, this method will return a different adapter depending on which database is
58
+ # being used
59
+ def self.adapter
60
+ model_class = ActiveRecord::Base
61
+ adapter_name = model_class.connection.adapter_name
62
+ case adapter_name
63
+ when /postgres/i
64
+ Pecorino::Postgres.new(model_class)
65
+ when /sqlite/i
66
+ Pecorino::Sqlite.new(model_class)
67
+ else
68
+ raise "Pecorino does not support #{adapter_name} just yet"
69
+ end
70
+ end
49
71
  end
data/pecorino.gemspec CHANGED
@@ -3,15 +3,15 @@
3
3
  require_relative "lib/pecorino/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "pecorino"
7
- spec.version = Pecorino::VERSION
8
- spec.authors = ["Julik Tarkhanov"]
9
- spec.email = ["me@julik.nl"]
10
-
11
- spec.summary = "Database-based rate limiter using leaky buckets"
12
- spec.description = "Pecorino allows you to define throttles and rate meters for your metered resources, all through your standard DB"
13
- spec.homepage = "https://github.com/cheddar-me/pecorino"
14
- spec.license = "MIT"
6
+ spec.name = "pecorino"
7
+ spec.version = Pecorino::VERSION
8
+ spec.authors = ["Julik Tarkhanov"]
9
+ spec.email = ["me@julik.nl"]
10
+
11
+ spec.summary = "Database-based rate limiter using leaky buckets"
12
+ spec.description = "Pecorino allows you to define throttles and rate meters for your metered resources, all through your standard DB"
13
+ spec.homepage = "https://github.com/cheddar-me/pecorino"
14
+ spec.license = "MIT"
15
15
  spec.required_ruby_version = ">= 2.4.0"
16
16
 
17
17
  # spec.metadata["allowed_push_host"] = "TODO: Set to 'https://mygemserver.com'"
@@ -25,15 +25,20 @@ Gem::Specification.new do |spec|
25
25
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
26
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
27
27
  end
28
- spec.bindir = "exe"
29
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
+ spec.bindir = "exe"
29
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
30
  spec.require_paths = ["lib"]
31
31
 
32
32
  # Uncomment to register a new dependency of your gem
33
33
  spec.add_dependency "activerecord", "~> 7"
34
- spec.add_dependency "pg"
35
- spec.add_development_dependency "activesupport", "~> 7"
36
- spec.add_development_dependency "rails", "~> 7"
34
+ spec.add_development_dependency "pg"
35
+ spec.add_development_dependency "sqlite3"
36
+ spec.add_development_dependency "activesupport", "~> 7.0"
37
+ spec.add_development_dependency "rake", "~> 13.0"
38
+ spec.add_development_dependency "minitest", "~> 5.0"
39
+ spec.add_development_dependency "standard"
40
+ spec.add_development_dependency "magic_frozen_string_literal"
41
+ spec.add_development_dependency "minitest-fail-fast"
37
42
 
38
43
  # For more information and examples about making a new gem, checkout our
39
44
  # guide at: https://bundler.io/guides/creating_gem.html
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pecorino
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
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: 2023-11-02 00:00:00.000000000 Z
11
+ date: 2024-01-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -31,7 +31,21 @@ dependencies:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: '0'
34
- type: :runtime
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
@@ -44,28 +58,84 @@ dependencies:
44
58
  requirements:
45
59
  - - "~>"
46
60
  - !ruby/object:Gem::Version
47
- version: '7'
61
+ version: '7.0'
48
62
  type: :development
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
66
  - - "~>"
53
67
  - !ruby/object:Gem::Version
54
- version: '7'
68
+ version: '7.0'
55
69
  - !ruby/object:Gem::Dependency
56
- name: rails
70
+ name: rake
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: '7'
75
+ version: '13.0'
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: '7'
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: minitest
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '5.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '5.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: standard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: magic_frozen_string_literal
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: minitest-fail-fast
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
69
139
  description: Pecorino allows you to define throttles and rate meters for your metered
70
140
  resources, all through your standard DB
71
141
  email:
@@ -74,7 +144,7 @@ executables: []
74
144
  extensions: []
75
145
  extra_rdoc_files: []
76
146
  files:
77
- - ".github/workflows/main.yml"
147
+ - ".github/workflows/ci.yml"
78
148
  - ".gitignore"
79
149
  - ".ruby-version"
80
150
  - CHANGELOG.md
@@ -87,7 +157,9 @@ files:
87
157
  - lib/pecorino/install_generator.rb
88
158
  - lib/pecorino/leaky_bucket.rb
89
159
  - lib/pecorino/migrations/create_pecorino_tables.rb.erb
160
+ - lib/pecorino/postgres.rb
90
161
  - lib/pecorino/railtie.rb
162
+ - lib/pecorino/sqlite.rb
91
163
  - lib/pecorino/throttle.rb
92
164
  - lib/pecorino/version.rb
93
165
  - pecorino.gemspec
@@ -1,16 +0,0 @@
1
- name: Ruby
2
-
3
- on: [push,pull_request]
4
-
5
- jobs:
6
- build:
7
- runs-on: ubuntu-latest
8
- steps:
9
- - uses: actions/checkout@v2
10
- - name: Set up Ruby
11
- uses: ruby/setup-ruby@v1
12
- with:
13
- ruby-version: 2.6.3
14
- bundler-cache: true
15
- - name: Run the default task
16
- run: bundle exec rake