Skip to content

Commit dd15f36

Browse files
authored
Merge pull request #11042 from neinteractiveliterature/nbudin/issue11033
Scale up background workers in addition to web workers
2 parents 7f5d40c + 8c5b38e commit dd15f36

10 files changed

Lines changed: 291 additions & 137 deletions

File tree

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,5 @@ end
189189

190190
gem "sentry-ruby", "~> 5.17"
191191
gem "sentry-rails", "~> 5.17"
192+
193+
gem "openssl", "~> 3.3"

Gemfile.lock

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ GEM
187187
crass (1.0.6)
188188
csv (3.3.5)
189189
dalli (3.2.8)
190-
date (3.4.1)
190+
date (3.5.0)
191191
dead_end (4.0.0)
192192
debug (1.11.0)
193193
irb (~> 1.10)
@@ -240,7 +240,7 @@ GEM
240240
dotenv (= 3.1.8)
241241
railties (>= 6.1)
242242
drb (2.2.3)
243-
erb (5.0.3)
243+
erb (5.1.3)
244244
erubi (1.13.1)
245245
erubis (2.7.0)
246246
eu_central_bank (1.7.0)
@@ -398,6 +398,7 @@ GEM
398398
oj (3.16.12)
399399
bigdecimal (>= 3.0)
400400
ostruct (>= 0.2)
401+
openssl (3.3.2)
401402
optimist (3.1.0)
402403
orm_adapter (0.5.0)
403404
ostruct (0.6.3)
@@ -502,9 +503,10 @@ GEM
502503
msgpack (>= 0.4.3)
503504
optimist (>= 3.0.0)
504505
rbtree (0.4.6)
505-
rdoc (6.14.2)
506+
rdoc (6.15.1)
506507
erb
507508
psych (>= 4.0.0)
509+
tsort
508510
recaptcha (5.21.1)
509511
redcarpet (3.6.1)
510512
regexp_parser (2.11.3)
@@ -717,6 +719,7 @@ DEPENDENCIES
717719
money-rails
718720
mysql2 (~> 0.5.3)
719721
oj (~> 3.16.0)
722+
openssl (~> 3.3)
720723
parallel
721724
pg
722725
pg_search

app/services/autoscale_servers_service.rb

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,23 +92,45 @@ def self.scaling_target_for(time)
9292
scaling_targets.max.clamp(MIN_INSTANCES, MAX_INSTANCES).ceil
9393
end
9494

95-
private
95+
def self.worker_scaling_target_for(time)
96+
web_scaling_target = scaling_target_for(time)
9697

97-
def inner_call
98-
adapter = HostingServiceAdapters::Base.find_adapter
99-
return unless adapter
98+
if web_scaling_target == MIN_INSTANCES
99+
1
100+
else
101+
2
102+
end
103+
end
100104

101-
scaling_target = self.class.scaling_target_for(Time.now)
102-
current_instance_count = adapter.fetch_instance_count
105+
def self.worker_instance_type_for(time)
106+
web_scaling_target = scaling_target_for(time)
103107

104-
if current_instance_count == scaling_target
105-
Rails.logger.info "Currently running #{current_instance_count} #{"instance".pluralize(current_instance_count)}; \
106-
no autoscaling needed"
108+
if web_scaling_target == MIN_INSTANCES
109+
:small
107110
else
108-
Rails.logger.info "Autoscaling to #{scaling_target} instances"
109-
adapter.update_instance_count(scaling_target)
111+
:large
110112
end
113+
end
114+
115+
private
116+
117+
def inner_call
118+
now = Time.now
119+
web_scaling_target = self.class.scaling_target_for(now)
120+
worker_scaling_target = self.class.worker_scaling_target_for(now)
121+
worker_instance_type = self.class.worker_instance_type_for(now)
122+
123+
adapter.apply_instance_counts(
124+
[
125+
{ group: :web, type: :small, count: web_scaling_target },
126+
{ group: :worker, type: worker_instance_type, count: worker_scaling_target }
127+
]
128+
)
111129

112130
success
113131
end
132+
133+
def adapter
134+
@adapter ||= HostingServiceAdapters.find_adapter
135+
end
114136
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module HostingServiceAdapters
2+
ADAPTER_CLASSES = [HostingServiceAdapters::Fly, HostingServiceAdapters::Heroku]
3+
4+
def self.find_adapter
5+
ADAPTER_CLASSES.map(&:new).find(&:applicable?)
6+
end
7+
end
Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
class HostingServiceAdapters::Base
2-
ADAPTER_CLASSES = [HostingServiceAdapters::Fly, HostingServiceAdapters::Heroku, HostingServiceAdapters::Render]
3-
4-
def self.find_adapter
5-
ADAPTER_CLASSES.map(&:new).find(&:applicable?)
6-
end
7-
82
def applicable?
93
raise NotImplementedError, "HostingServiceAdapters::Base subclasses must implement #applicable?"
104
end
115

12-
def fetch_instance_count
13-
raise NotImplementedError, "HostingServiceAdapters::Base subclasses must implement #fetch_instance_count"
6+
def instances_state
7+
raise NotImplementedError, "HostingServiceAdapters::Base subclasses must implement #instances_state"
8+
end
9+
10+
def apply_instance_counts(target_counts)
11+
target_groups = Set.new(target_counts.map { |target| { group: target[:group], type: target[:type] } })
12+
13+
target_counts.each do |target|
14+
update_instance_group(group: target[:group], type: target[:type], count: target[:count])
15+
end
16+
17+
instances_state.counts.each do |row|
18+
next if target_groups.include?(row.slice(:group, :type))
19+
update_instance_group(group: row[:group], type: row[:type], count: 0)
20+
end
21+
22+
instances_state
1423
end
1524

16-
def update_instance_count(instance_count)
17-
raise NotImplementedError, "HostingServiceAdapters::Base subclasses must implement #update_instance_count"
25+
def update_instance_group(group:, type:, count:)
26+
raise NotImplementedError, "HostingServiceAdapters::Base subclasses must implement #update_instance_group"
1827
end
1928
end
Lines changed: 142 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,167 @@
1-
require "fly.io-rails/machines"
2-
31
class HostingServiceAdapters::Fly < HostingServiceAdapters::Base
2+
SMALL_GUEST = { "cpu_kind" => "shared", "cpus" => 1, "memory_mb" => 512 }
3+
LARGE_GUEST = { "cpu_kind" => "performance", "cpus" => 1, "memory_mb" => 2048 }
4+
5+
class Machine < HostingServiceAdapters::Instance
6+
attr_reader :id, :state, :config, :region, :instance_id, :name
7+
8+
def initialize(id:, instance_id:, state:, config:, region:, name:, **args)
9+
super(**args)
10+
@id = id
11+
@instance_id = instance_id
12+
@state = state
13+
@config = config
14+
@region = region
15+
@name = name
16+
end
17+
end
18+
419
def applicable?
520
ENV["FLY_APP_NAME"].present? && ENV["FLY_API_TOKEN"].present?
621
end
722

8-
def fetch_instance_count
9-
web_machines.count
23+
def instances_state
24+
@instances_state ||=
25+
HostingServiceAdapters::InstancesState.new(
26+
instances: current_machines.map { |machine| instance_for_machine(machine) }
27+
)
28+
end
29+
30+
def invalidate_state
31+
@current_machines = nil
32+
@instances_state = nil
33+
end
34+
35+
def force_refresh_state
36+
invalidate_state
37+
instances_state
1038
end
1139

12-
def update_instance_count(instance_count)
13-
raise "Instance count must be at least 1" if instance_count < 1
40+
def update_instance_group(group:, type:, count:)
41+
current_instances = instances_state.find_instances(group:, type:)
1442

15-
if instance_count > fetch_instance_count
16-
scale_up(instance_count - fetch_instance_count)
17-
elsif instance_count < fetch_instance_count
18-
scale_down(fetch_instance_count - instance_count)
43+
if current_instances.size < count
44+
create_machines(group:, type:, count: count - current_instances.size)
45+
invalidate_state
46+
elsif current_instances.size > count
47+
destroy_machines(current_instances.first(current_instances.size - count))
48+
invalidate_state
1949
end
2050
end
2151

2252
private
2353

24-
def scale_up(amount)
25-
config = web_machines.first[:config]
26-
created_machines =
27-
Array
28-
.new(amount)
29-
.map do
30-
Fly::Machines.create_and_start_machine(
31-
ENV.fetch("FLY_APP_NAME"),
32-
{ config: config, region: web_machines.first[:region] }
33-
)
54+
def machines_api_base
55+
@machines_api_base ||= ENV["FLY_PRIVATE_IP"].present? ? "http://_api.internal:4280" : "https://api.machines.dev"
56+
end
57+
58+
def machines_api
59+
@machines_api ||=
60+
Faraday.new(url: machines_api_base) do |builder|
61+
builder.request :authorization, "Bearer", -> { ENV.fetch("FLY_API_TOKEN") }
62+
builder.request :json
63+
builder.response :json
64+
builder.response :raise_error
65+
end
66+
end
67+
68+
def current_machines
69+
@current_machines ||= machines_api.get("/v1/apps/#{ENV.fetch("FLY_APP_NAME")}/machines").body
70+
end
71+
72+
def instance_for_machine(machine) # rubocop:disable Metrics/MethodLength
73+
guest = machine.dig("config", "guest")
74+
process_group = machine.dig("config", "metadata", "fly_process_group")
75+
76+
Machine.new(
77+
id: machine["id"],
78+
instance_id: machine["instance_id"],
79+
name: machine["name"],
80+
state: machine["state"],
81+
config: machine["config"],
82+
region: machine["region"],
83+
group:
84+
case process_group
85+
when "web"
86+
:web
87+
when "shoryuken"
88+
:worker
89+
else
90+
:other
91+
end,
92+
type:
93+
case guest
94+
when SMALL_GUEST
95+
:small
96+
when LARGE_GUEST
97+
:large
98+
else
99+
:other
34100
end
35-
Rails.logger.info "Created and started Fly machines: #{created_machines.pluck(:name).join(", ")}"
36-
Rails.logger.info "Waiting for all machines to start"
37-
created_machines.each do |machine|
38-
Fly::Machines.wait_for_machine(ENV.fetch("FLY_APP_NAME"), machine[:id], status: "started")
39-
end
40-
created_machines
101+
)
41102
end
42103

43-
def scale_down(amount)
44-
to_destroy = web_machines.first(amount)
45-
to_stop = to_destroy.filter { |machine| machine[:state] != "stopped" }
46-
to_stop.each do |machine|
47-
Rails.logger.info "Stopping Fly machine #{machine[:name]}"
48-
Fly::Machines.stop_machine(ENV.fetch("FLY_APP_NAME"), machine[:id])
104+
def guest_config_for_type(type)
105+
case type
106+
when :small
107+
SMALL_GUEST
108+
when :large
109+
LARGE_GUEST
49110
end
50-
unless to_stop.empty?
51-
Rails.logger.info "Waiting for all machines to stop"
52-
to_stop.each do |machine|
53-
Fly::Machines.wait_for_machine(ENV.fetch("FLY_APP_NAME"), machine[:id], status: "stopped")
54-
end
111+
end
112+
113+
def fly_process_group_for_group(group)
114+
case group
115+
when :web
116+
"web"
117+
when :worker
118+
"shoryuken"
55119
end
120+
end
121+
122+
def machine_create_body(group:, type:)
123+
template_machine = instances_state.find_instances(group:).first
124+
config_template =
125+
template_machine.config.merge(
126+
"guest" => guest_config_for_type(type),
127+
"metadata" => {
128+
"fly_process_group" => fly_process_group_for_group(group)
129+
}
130+
)
131+
{ config: config_template, region: template_machine.region }
132+
end
133+
134+
def create_machines(group:, type:, count:)
135+
create_body = machine_create_body(group:, type:)
56136

57-
to_destroy.each do |machine|
58-
Rails.logger.info "Deleting Fly machine #{machine[:name]}"
59-
Fly::Machines.delete_machine(ENV.fetch("FLY_APP_NAME"), machine[:id])
137+
Rails.logger.info "Creating #{count} #{type} #{group} machine(s)"
138+
created_machines =
139+
Array.new(count) { |_n| machines_api.post("/v1/apps/#{ENV.fetch("FLY_APP_NAME")}/machines", create_body).body }
140+
Rails.logger.info "Created and started Fly machines: #{created_machines.pluck("name").join(", ")}"
141+
142+
Rails.logger.info "Waiting for all machines to start"
143+
created_machines.each do |machine|
144+
machines_api.get("/v1/apps/#{ENV.fetch("FLY_APP_NAME")}/machines/#{machine["id"]}/wait?state=started")
60145
end
61146

62-
to_destroy
147+
created_machines
63148
end
64149

65-
def web_machines
66-
@web_machines ||=
67-
begin
68-
Fly::Machines.fly_api_hostname!
69-
Fly::Machines
70-
.list_machines(ENV.fetch("FLY_APP_NAME"), nil)
71-
.filter { |machine| machine[:config][:metadata][:fly_process_group] == "web" }
72-
end
150+
def destroy_machines(instances)
151+
instances.each do |instance|
152+
Rails.logger.info "Stopping Fly machine #{instance.name}"
153+
machines_api.post("/v1/apps/#{ENV.fetch("FLY_APP_NAME")}/machines/#{instance.id}/stop")
154+
end
155+
156+
Rails.logger.info "Waiting for all machines to stop"
157+
instances.each do |instance|
158+
machines_api.get(
159+
"/v1/apps/#{ENV.fetch("FLY_APP_NAME")}/machines/#{instance.id}/wait?\
160+
state=stopped&instance_id=#{instance.instance_id}"
161+
)
162+
163+
Rails.logger.info "Destroying Fly machine #{instance.name}"
164+
machines_api.delete("/v1/apps/#{ENV.fetch("FLY_APP_NAME")}/machines/#{instance.id}")
165+
end
73166
end
74167
end

0 commit comments

Comments
 (0)