pecorino 0.1.1 → 0.2.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: bfcca80bad8895a9b45ed8a3ed0afe06b52a0c6a851d8506b102a82b5375c2a3
4
+ data.tar.gz: 7f57dd803797acfdf29a8d7cae31854528012af249887ae49e398b992a48f9d4
5
5
  SHA512:
6
- metadata.gz: b9fbe0ec9ca780eb2e546b0f47bfcb0b4c579ac2f688dc59b7ebbcad39ebba875759fe62982c80b8f04e39553df6d1a64f7e8e9300d8f32b6d5f57c7c8a68405
7
- data.tar.gz: c909b8bb23045ef42eca9346f8956744aeb2f97d5c5446f62f5b16c808b56d99abbc291e5a40d2de31fa2027b4ed789567d51af98e44a23b4c0c2062cf7a71cd
6
+ metadata.gz: dea8f3c693c1d9b5412cdc50cd333d1de6b92d90ec1358ae3c3f5d03fef685649bccd4f6fcc8757e04aa7aba5b13411b325b69b374be0bf30a86d10c16977613
7
+ data.tar.gz: ee00695a622947cbdc14a300d73d06a850abd2357b3c1357e7904e7c93f2ea5513a4edbb98349a896b5b39cc57b08e889a221e78319e5344f778000dd7059f06
@@ -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,8 @@
1
- ## [Unreleased]
1
+ ## [0.2.0] - 2024-01-09
2
+
3
+ - [Add support for SQLite](https://github.com/cheddar-me/pecorino/pull/9)
4
+ - [Use comparisons in SQL to determine whether the leaky bucket did overflow](https://github.com/cheddar-me/pecorino/pull/8)
5
+ - [Change the way Structs are defined to appease Tapioca/Sorbet](https://github.com/cheddar-me/pecorino/pull/6)
2
6
 
3
7
  ## [0.1.0] - 2023-10-30
4
8
 
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,24 +17,35 @@ 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
24
-
25
- ## Usage
26
-
27
- First, add and run the migration to create the pecorino tables:
28
-
29
20
  $ bin/rails g pecorino:install
30
21
  $ bin/rails db:migrate
31
22
 
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.
23
+ ## Usage
24
+
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.
33
26
 
34
27
  ```ruby
35
28
  throttle = Pecorino::Throttle.new(key: "vault", leak_rate: 5, capacity: 5)
36
29
  throttle.request!
37
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
+ ```
39
+
40
+ and in a Rack application you can rescue inline:
41
+
42
+ ```ruby
43
+ def call(env)
44
+ # ...your code
45
+ rescue Pecorino::Throttle::Throttled => e
46
+ [429, {"Retry-After" => e.retry_after.to_s}, []]
47
+ end
48
+ ```
38
49
 
39
50
  The exception has an attribute called `retry_after` which you can use to render the appropriate 429 response.
40
51
 
@@ -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,7 +59,7 @@ 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
+ # Creates a new LeakyBucket. The object controls 1 row in the database is
63
63
  # specific to the bucket key.
64
64
  #
65
65
  # @param key[String] the key for the bucket. The key also gets used
@@ -86,7 +86,8 @@ class Pecorino::LeakyBucket
86
86
  # @param n_tokens[Float]
87
87
  # @return [State] the state of the bucket after the operation
88
88
  def fillup(n_tokens)
89
- add_tokens(n_tokens.to_f)
89
+ capped_level_after_fillup, did_overflow = Pecorino.adapter.add_tokens(capacity: @capacity, key: @key, leak_rate: @leak_rate, n_tokens: n_tokens)
90
+ State.new(capped_level_after_fillup, did_overflow)
90
91
  end
91
92
 
92
93
  # Returns the current state of the bucket, containing the level and whether the bucket is full.
@@ -94,34 +95,8 @@ class Pecorino::LeakyBucket
94
95
  #
95
96
  # @return [State] the snapshotted state of the bucket at time of query
96
97
  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)
98
+ current_level, is_full = Pecorino.adapter.state(key: @key, capacity: @capacity, leak_rate: @leak_rate)
99
+ State.new(current_level, is_full)
125
100
  end
126
101
 
127
102
  # Tells whether the bucket can accept the amount of tokens without overflowing.
@@ -135,58 +110,4 @@ class Pecorino::LeakyBucket
135
110
  def able_to_accept?(n_tokens)
136
111
  (state.level + n_tokens) < @capacity
137
112
  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
113
  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
@@ -46,10 +46,10 @@ class Pecorino::Throttle
46
46
  # @param block_for[Numeric] the number of seconds to block any further requests for
47
47
  # @param leaky_bucket_options Options for `Pecorino::LeakyBucket.new`
48
48
  # @see PecorinoLeakyBucket.new
49
- def initialize(key:, block_for: 30, **leaky_bucket_options)
49
+ def initialize(key:, block_for: 30, **)
50
50
  @key = key.to_s
51
51
  @block_for = block_for.to_f
52
- @bucket = Pecorino::LeakyBucket.new(key:, **leaky_bucket_options)
52
+ @bucket = Pecorino::LeakyBucket.new(key:, **)
53
53
  end
54
54
 
55
55
  # Tells whether the throttle will let this number of requests pass without raising
@@ -60,8 +60,7 @@ class Pecorino::Throttle
60
60
  # @param n_tokens[Float]
61
61
  # @return [boolean]
62
62
  def able_to_accept?(n_tokens = 1)
63
- conn = ActiveRecord::Base.connection
64
- !blocked_until(conn) && @bucket.able_to_accept?(n_tokens)
63
+ Pecorino.adapter.blocked_until(key: @key).nil? && @bucket.able_to_accept?(n_tokens)
65
64
  end
66
65
 
67
66
  # Register that a request is being performed. Will raise Throttled
@@ -98,35 +97,14 @@ class Pecorino::Throttle
98
97
  #
99
98
  # @return [State] the state of the throttle after filling up the leaky bucket / trying to pass the block
100
99
  def request(n = 1)
101
- conn = ActiveRecord::Base.connection
102
- existing_blocked_until = blocked_until(conn)
100
+ existing_blocked_until = Pecorino.adapter.blocked_until(key: @key)
103
101
  return State.new(existing_blocked_until.utc) if existing_blocked_until
104
102
 
105
103
  # Topup the leaky bucket
106
104
  return State.new(nil) unless @bucket.fillup(n.to_f).full?
107
105
 
108
106
  # 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) }
107
+ fresh_blocked_until = Pecorino.adapter.set_block(key: @key, block_for: @block_for)
121
108
  State.new(fresh_blocked_until.utc)
122
109
  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
110
  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.2.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.2.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-09 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