pecorino 0.1.0 → 0.2.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/.gitignore +1 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +5 -1
- data/Gemfile +0 -4
- data/README.md +40 -16
- data/Rakefile +7 -1
- data/lib/pecorino/install_generator.rb +5 -4
- data/lib/pecorino/leaky_bucket.rb +7 -86
- 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 +6 -28
- data/lib/pecorino/version.rb +1 -1
- data/lib/pecorino.rb +26 -4
- data/pecorino.gemspec +19 -14
- metadata +83 -12
- data/.github/workflows/main.yml +0 -16
- data/Gemfile.lock +0 -156
- /data/lib/pecorino/migrations/{create_raclette_tables.rb.erb → create_pecorino_tables.rb.erb} +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bfcca80bad8895a9b45ed8a3ed0afe06b52a0c6a851d8506b102a82b5375c2a3
|
4
|
+
data.tar.gz: 7f57dd803797acfdf29a8d7cae31854528012af249887ae49e398b992a48f9d4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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/.gitignore
CHANGED
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
3.
|
1
|
+
3.2.2
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,8 @@
|
|
1
|
-
## [
|
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
data/README.md
CHANGED
@@ -2,7 +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
|
+
|
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.
|
6
8
|
|
7
9
|
## Installation
|
8
10
|
|
@@ -15,24 +17,35 @@ gem 'pecorino'
|
|
15
17
|
And then execute:
|
16
18
|
|
17
19
|
$ bundle install
|
18
|
-
|
19
|
-
Or install it yourself as:
|
20
|
-
|
21
|
-
$ gem install pecorino
|
22
|
-
|
23
|
-
## Usage
|
24
|
-
|
25
|
-
First, add and run the migration to create the pecorino tables:
|
26
|
-
|
27
20
|
$ bin/rails g pecorino:install
|
28
21
|
$ bin/rails db:migrate
|
29
22
|
|
30
|
-
|
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.
|
31
26
|
|
32
27
|
```ruby
|
33
28
|
throttle = Pecorino::Throttle.new(key: "vault", leak_rate: 5, capacity: 5)
|
34
29
|
throttle.request!
|
35
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
|
+
```
|
36
49
|
|
37
50
|
The exception has an attribute called `retry_after` which you can use to render the appropriate 429 response.
|
38
51
|
|
@@ -58,7 +71,7 @@ Sometimes you don't want to use a throttle, but you want to track the amount add
|
|
58
71
|
|
59
72
|
|
60
73
|
```ruby
|
61
|
-
b = Pecorino::LeakyBucket.new(key: "some_b", capacity: 100, leak_rate:
|
74
|
+
b = Pecorino::LeakyBucket.new(key: "some_b", capacity: 100, leak_rate: 1)
|
62
75
|
b.fillup(2) #=> Pecorino::LeakyBucket::State(full?: false, level: 2.0)
|
63
76
|
sleep 0.2
|
64
77
|
b.state #=> Pecorino::LeakyBucket::State(full?: false, level: 1.8)
|
@@ -70,17 +83,28 @@ Check out the inline YARD documentation for more options.
|
|
70
83
|
|
71
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:
|
72
85
|
|
73
|
-
|
86
|
+
```ruby
|
87
|
+
Pecorino.prune!
|
88
|
+
```
|
89
|
+
|
90
|
+
## Using unlogged tables for reduced replication load (PostgreSQL)
|
91
|
+
|
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:
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
ActiveRecord::Base.connection.execute("ALTER TABLE pecorino_leaky_buckets SET UNLOGGED")
|
96
|
+
ActiveRecord::Base.connection.execute("ALTER TABLE pecorino_blocks SET UNLOGGED")
|
97
|
+
```
|
74
98
|
|
75
99
|
## Development
|
76
100
|
|
77
|
-
After checking out the repo, run `bundle
|
101
|
+
After checking out the repo, run `bundle`. Then, run `rake test` to run the tests.
|
78
102
|
|
79
103
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
80
104
|
|
81
105
|
## Contributing
|
82
106
|
|
83
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
107
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/cheddar-me/pecorino. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/cheddar-me/pecorino/blob/main/CODE_OF_CONDUCT.md).
|
84
108
|
|
85
109
|
## License
|
86
110
|
|
@@ -88,4 +112,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
88
112
|
|
89
113
|
## Code of Conduct
|
90
114
|
|
91
|
-
Everyone interacting in the Pecorino project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/
|
115
|
+
Everyone interacting in the Pecorino project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/cheddar-me/pecorino/blob/main/CODE_OF_CONDUCT.md).
|
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,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
|
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
|
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
|
-
|
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)
|
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
|
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
|
@@ -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, **
|
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:, **
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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.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:
|
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: :
|
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,21 +144,22 @@ 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
|
81
151
|
- CODE_OF_CONDUCT.md
|
82
152
|
- Gemfile
|
83
|
-
- Gemfile.lock
|
84
153
|
- LICENSE.txt
|
85
154
|
- README.md
|
86
155
|
- Rakefile
|
87
156
|
- lib/pecorino.rb
|
88
157
|
- lib/pecorino/install_generator.rb
|
89
158
|
- lib/pecorino/leaky_bucket.rb
|
90
|
-
- lib/pecorino/migrations/
|
159
|
+
- lib/pecorino/migrations/create_pecorino_tables.rb.erb
|
160
|
+
- lib/pecorino/postgres.rb
|
91
161
|
- lib/pecorino/railtie.rb
|
162
|
+
- lib/pecorino/sqlite.rb
|
92
163
|
- lib/pecorino/throttle.rb
|
93
164
|
- lib/pecorino/version.rb
|
94
165
|
- pecorino.gemspec
|
@@ -114,7 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
114
185
|
- !ruby/object:Gem::Version
|
115
186
|
version: '0'
|
116
187
|
requirements: []
|
117
|
-
rubygems_version: 3.
|
188
|
+
rubygems_version: 3.4.10
|
118
189
|
signing_key:
|
119
190
|
specification_version: 4
|
120
191
|
summary: Database-based rate limiter using leaky buckets
|
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
|
data/Gemfile.lock
DELETED
@@ -1,156 +0,0 @@
|
|
1
|
-
PATH
|
2
|
-
remote: .
|
3
|
-
specs:
|
4
|
-
pecorino (0.1.0)
|
5
|
-
activerecord (~> 7)
|
6
|
-
pg
|
7
|
-
|
8
|
-
GEM
|
9
|
-
remote: https://rubygems.org/
|
10
|
-
specs:
|
11
|
-
actioncable (7.0.4)
|
12
|
-
actionpack (= 7.0.4)
|
13
|
-
activesupport (= 7.0.4)
|
14
|
-
nio4r (~> 2.0)
|
15
|
-
websocket-driver (>= 0.6.1)
|
16
|
-
actionmailbox (7.0.4)
|
17
|
-
actionpack (= 7.0.4)
|
18
|
-
activejob (= 7.0.4)
|
19
|
-
activerecord (= 7.0.4)
|
20
|
-
activestorage (= 7.0.4)
|
21
|
-
activesupport (= 7.0.4)
|
22
|
-
mail (>= 2.7.1)
|
23
|
-
net-imap
|
24
|
-
net-pop
|
25
|
-
net-smtp
|
26
|
-
actionmailer (7.0.4)
|
27
|
-
actionpack (= 7.0.4)
|
28
|
-
actionview (= 7.0.4)
|
29
|
-
activejob (= 7.0.4)
|
30
|
-
activesupport (= 7.0.4)
|
31
|
-
mail (~> 2.5, >= 2.5.4)
|
32
|
-
net-imap
|
33
|
-
net-pop
|
34
|
-
net-smtp
|
35
|
-
rails-dom-testing (~> 2.0)
|
36
|
-
actionpack (7.0.4)
|
37
|
-
actionview (= 7.0.4)
|
38
|
-
activesupport (= 7.0.4)
|
39
|
-
rack (~> 2.0, >= 2.2.0)
|
40
|
-
rack-test (>= 0.6.3)
|
41
|
-
rails-dom-testing (~> 2.0)
|
42
|
-
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
43
|
-
actiontext (7.0.4)
|
44
|
-
actionpack (= 7.0.4)
|
45
|
-
activerecord (= 7.0.4)
|
46
|
-
activestorage (= 7.0.4)
|
47
|
-
activesupport (= 7.0.4)
|
48
|
-
globalid (>= 0.6.0)
|
49
|
-
nokogiri (>= 1.8.5)
|
50
|
-
actionview (7.0.4)
|
51
|
-
activesupport (= 7.0.4)
|
52
|
-
builder (~> 3.1)
|
53
|
-
erubi (~> 1.4)
|
54
|
-
rails-dom-testing (~> 2.0)
|
55
|
-
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
56
|
-
activejob (7.0.4)
|
57
|
-
activesupport (= 7.0.4)
|
58
|
-
globalid (>= 0.3.6)
|
59
|
-
activemodel (7.0.4)
|
60
|
-
activesupport (= 7.0.4)
|
61
|
-
activerecord (7.0.4)
|
62
|
-
activemodel (= 7.0.4)
|
63
|
-
activesupport (= 7.0.4)
|
64
|
-
activestorage (7.0.4)
|
65
|
-
actionpack (= 7.0.4)
|
66
|
-
activejob (= 7.0.4)
|
67
|
-
activerecord (= 7.0.4)
|
68
|
-
activesupport (= 7.0.4)
|
69
|
-
marcel (~> 1.0)
|
70
|
-
mini_mime (>= 1.1.0)
|
71
|
-
activesupport (7.0.4)
|
72
|
-
concurrent-ruby (~> 1.0, >= 1.0.2)
|
73
|
-
i18n (>= 1.6, < 2)
|
74
|
-
minitest (>= 5.1)
|
75
|
-
tzinfo (~> 2.0)
|
76
|
-
builder (3.2.4)
|
77
|
-
concurrent-ruby (1.1.10)
|
78
|
-
crass (1.0.6)
|
79
|
-
erubi (1.11.0)
|
80
|
-
globalid (1.0.0)
|
81
|
-
activesupport (>= 5.0)
|
82
|
-
i18n (1.12.0)
|
83
|
-
concurrent-ruby (~> 1.0)
|
84
|
-
loofah (2.19.0)
|
85
|
-
crass (~> 1.0.2)
|
86
|
-
nokogiri (>= 1.5.9)
|
87
|
-
mail (2.7.1)
|
88
|
-
mini_mime (>= 0.1.1)
|
89
|
-
marcel (1.0.2)
|
90
|
-
method_source (1.0.0)
|
91
|
-
mini_mime (1.1.2)
|
92
|
-
minitest (5.16.3)
|
93
|
-
net-imap (0.3.1)
|
94
|
-
net-protocol
|
95
|
-
net-pop (0.1.2)
|
96
|
-
net-protocol
|
97
|
-
net-protocol (0.1.3)
|
98
|
-
timeout
|
99
|
-
net-smtp (0.3.2)
|
100
|
-
net-protocol
|
101
|
-
nio4r (2.5.8)
|
102
|
-
nokogiri (1.13.8-x86_64-darwin)
|
103
|
-
racc (~> 1.4)
|
104
|
-
pg (1.3.2)
|
105
|
-
racc (1.6.0)
|
106
|
-
rack (2.2.4)
|
107
|
-
rack-test (2.0.2)
|
108
|
-
rack (>= 1.3)
|
109
|
-
rails (7.0.4)
|
110
|
-
actioncable (= 7.0.4)
|
111
|
-
actionmailbox (= 7.0.4)
|
112
|
-
actionmailer (= 7.0.4)
|
113
|
-
actionpack (= 7.0.4)
|
114
|
-
actiontext (= 7.0.4)
|
115
|
-
actionview (= 7.0.4)
|
116
|
-
activejob (= 7.0.4)
|
117
|
-
activemodel (= 7.0.4)
|
118
|
-
activerecord (= 7.0.4)
|
119
|
-
activestorage (= 7.0.4)
|
120
|
-
activesupport (= 7.0.4)
|
121
|
-
bundler (>= 1.15.0)
|
122
|
-
railties (= 7.0.4)
|
123
|
-
rails-dom-testing (2.0.3)
|
124
|
-
activesupport (>= 4.2.0)
|
125
|
-
nokogiri (>= 1.6)
|
126
|
-
rails-html-sanitizer (1.4.3)
|
127
|
-
loofah (~> 2.3)
|
128
|
-
railties (7.0.4)
|
129
|
-
actionpack (= 7.0.4)
|
130
|
-
activesupport (= 7.0.4)
|
131
|
-
method_source
|
132
|
-
rake (>= 12.2)
|
133
|
-
thor (~> 1.0)
|
134
|
-
zeitwerk (~> 2.5)
|
135
|
-
rake (13.0.6)
|
136
|
-
thor (1.2.1)
|
137
|
-
timeout (0.3.0)
|
138
|
-
tzinfo (2.0.5)
|
139
|
-
concurrent-ruby (~> 1.0)
|
140
|
-
websocket-driver (0.7.5)
|
141
|
-
websocket-extensions (>= 0.1.0)
|
142
|
-
websocket-extensions (0.1.5)
|
143
|
-
zeitwerk (2.6.1)
|
144
|
-
|
145
|
-
PLATFORMS
|
146
|
-
x86_64-darwin-19
|
147
|
-
|
148
|
-
DEPENDENCIES
|
149
|
-
activesupport (~> 7)
|
150
|
-
minitest (~> 5.0)
|
151
|
-
pecorino!
|
152
|
-
rails (~> 7)
|
153
|
-
rake (~> 13.0)
|
154
|
-
|
155
|
-
BUNDLED WITH
|
156
|
-
2.3.5
|
/data/lib/pecorino/migrations/{create_raclette_tables.rb.erb → create_pecorino_tables.rb.erb}
RENAMED
File without changes
|