Skip to content

Commit d0906e4

Browse files
Actually rate limit when needed (#610)
1 parent af8c8ee commit d0906e4

File tree

9 files changed

+98
-4
lines changed

9 files changed

+98
-4
lines changed

app/jobs/application_job.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
class ApplicationJob < ActiveJob::Base
22
discard_on ActiveJob::DeserializationError # most likely a record that's not there anymore
3+
4+
include RateLimitable
35
end

app/jobs/hackathons/website_archival_job.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class Hackathons::WebsiteArchivalJob < ApplicationJob
2-
limits_concurrency to: 15, duration: 1.minute, group: "Wayback Machine", key: "API"
2+
rate_limit "Wayback Machine", to: 15, within: 1.minute
33
queue_as :low
44

55
def perform(hackathon)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
module RateLimitable
2+
extend ActiveSupport::Concern
3+
4+
class Limit < StandardError
5+
attr_reader :duration
6+
def initialize(duration: nil)
7+
@duration = duration
8+
super
9+
end
10+
end
11+
12+
class_methods do
13+
def rate_limit(key = name, to:, within:)
14+
around_perform do |job, block|
15+
unless Lock.acquire(key, limit: to, duration: within) { block }
16+
raise Limit.new(duration:)
17+
end
18+
end
19+
end
20+
end
21+
22+
included do
23+
rescue_from RateLimitable::Limit do |limit|
24+
retry_job wait: limit.duration
25+
logger.info "#{self.class.name} #{job_id} was rate limited for at least #{limit.duration.inspect}"
26+
end
27+
end
28+
end

app/models/hackathon/website/archivable.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def archive_with(job_id)
4646
end
4747

4848
class FollowUpJob < ApplicationJob
49-
limits_concurrency to: 15, duration: 1.minute, group: "Wayback Machine", key: "API"
49+
rate_limit "Wayback Machine", to: 15, within: 1.minute
5050
queue_as :low
5151

5252
def perform(hackathon, id)

app/models/lock.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
class Lock < ApplicationRecord
2+
scope :expired, -> { where "expiration <= ?", Time.now }
3+
4+
class << self
5+
def acquire(key, limit: 1, duration: nil, &block)
6+
lock = nil
7+
transaction do
8+
lock = find_by(key:) || create!(key:, expiration: duration&.from_now)
9+
if lock.capacity == limit
10+
return false
11+
end
12+
end
13+
14+
lock.acquire
15+
16+
begin
17+
yield block
18+
ensure
19+
lock.release
20+
end
21+
end
22+
end
23+
24+
def acquire(capacity = 1)
25+
increment!(:capacity, capacity)
26+
end
27+
28+
def release(quantity = 1)
29+
transaction do
30+
reload
31+
if capacity == 1
32+
destroy!
33+
else
34+
decrement!(:capacity, quantity)
35+
end
36+
end
37+
end
38+
end
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
Rails.application.config.after_initialize do
22
# AWS SES rate-limits to 14/second, so we'll set it to 10 to be safe
3-
ActionMailer::MailDeliveryJob.limits_concurrency key: "mail", to: 10, duration: 1.second
3+
ActionMailer::MailDeliveryJob.include(RateLimitable)
4+
.rate_limit to: 10, within: 1.second
45
end

config/schedule.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
lock_maintenance:
2+
cron: "every second"
3+
command: "Lock.expired.delete_all"
4+
15
digests_delivery:
26
cron: "every tuesday at 10am on America/Los_Angeles"
37
class: "Hackathons::DigestsDeliveryJob"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class CreateLocks < ActiveRecord::Migration[8.1]
2+
def change
3+
create_table :locks do |t|
4+
t.string :key, null: false, index: {unique: true}
5+
t.integer :capacity, null: false, default: 0
6+
t.datetime :expiration, index: true
7+
8+
t.timestamps
9+
end
10+
end
11+
end

db/schema.rb

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)