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 +4 -4
- data/.github/workflows/ci.yml +76 -0
- data/CHANGELOG.md +10 -1
- data/Gemfile +0 -4
- data/README.md +28 -15
- data/Rakefile +7 -1
- data/lib/pecorino/install_generator.rb +5 -4
- data/lib/pecorino/leaky_bucket.rb +29 -89
- data/lib/pecorino/postgres.rb +107 -0
- data/lib/pecorino/railtie.rb +3 -1
- data/lib/pecorino/sqlite.rb +125 -0
- data/lib/pecorino/throttle.rb +9 -30
- data/lib/pecorino/version.rb +1 -1
- data/lib/pecorino.rb +26 -4
- data/pecorino.gemspec +19 -14
- metadata +81 -9
- data/.github/workflows/main.yml +0 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2f94aa734cb0bb50657f5484bbdd8bfcaabc7c2d6b7d9329361d41456ba49db6
|
4
|
+
data.tar.gz: 97e01c53e828092ce60be1412a288a70446ec9cc5ab783a6fa6e3ba147de1ee5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
## [
|
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
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
|
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)
|
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
|
-
|
22
|
-
|
23
|
-
$ gem install pecorino
|
20
|
+
$ bin/rails g pecorino:install
|
21
|
+
$ bin/rails db:migrate
|
24
22
|
|
25
23
|
## Usage
|
26
24
|
|
27
|
-
|
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
|
-
|
30
|
-
|
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
|
-
|
40
|
+
and in a Rack application you can rescue inline:
|
33
41
|
|
34
42
|
```ruby
|
35
|
-
|
36
|
-
|
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}",
|
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
|
-
|
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
|
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
|
-
|
3
|
-
require
|
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
|
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
|
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
|
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
|
-
|
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
|
-
#
|
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:,
|
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
|
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
|
-
|
98
|
-
|
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
|
data/lib/pecorino/railtie.rb
CHANGED
@@ -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
|
data/lib/pecorino/throttle.rb
CHANGED
@@ -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
|
-
|
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:
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/pecorino/version.rb
CHANGED
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
|
-
#
|
27
|
-
#
|
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
|
7
|
-
spec.version
|
8
|
-
spec.authors
|
9
|
-
spec.email
|
10
|
-
|
11
|
-
spec.summary
|
12
|
-
spec.description
|
13
|
-
spec.homepage
|
14
|
-
spec.license
|
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
|
29
|
-
spec.executables
|
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.
|
35
|
-
spec.add_development_dependency "
|
36
|
-
spec.add_development_dependency "
|
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.
|
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:
|
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: :
|
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:
|
70
|
+
name: rake
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
58
72
|
requirements:
|
59
73
|
- - "~>"
|
60
74
|
- !ruby/object:Gem::Version
|
61
|
-
version: '
|
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: '
|
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/
|
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
|
data/.github/workflows/main.yml
DELETED
@@ -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
|