fly-ruby 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +6 -0
- data/README.md +3 -3
- data/lib/fly-ruby/configuration.rb +53 -6
- data/lib/fly-ruby/headers.rb +16 -0
- data/lib/fly-ruby/railtie.rb +11 -16
- data/lib/fly-ruby/regional_database.rb +64 -80
- data/lib/fly-ruby/version.rb +3 -1
- data/lib/fly-ruby.rb +4 -0
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ea1ca64bcd11ed8a60774f0d8ec255ffb49bc6b8e21a8bd32e7410068bec35c0
|
4
|
+
data.tar.gz: fb55ad6c98cb631bf202aea206c53a9b2e9a7c738cd9835095dd647011cfc7f6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7c3df04f8bb9f97bfb87903b0fffc14ef2b320802e1183752c07f8b769b629f6c66af21ae0e4bb5a23a2608cfdfdda2f3b1c387e3edcd1f64b5d2474a79f7a6f
|
7
|
+
data.tar.gz: ff4ca7c18d4b18ec237c8e1c59bce88dce849198934ed1d42c28ab2a081b577f8fa2cc38930d0478ec94a5bee580e2f12649b38d3ad46d176a774edaba60446c
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,12 @@
|
|
2
2
|
|
3
3
|
### Bug fixes
|
4
4
|
|
5
|
+
- Run the database exception handler at the bottom of the stack to ensure it will take priority over other exception handlers
|
6
|
+
|
7
|
+
## 0.2.1
|
8
|
+
|
9
|
+
### Bug fixes
|
10
|
+
|
5
11
|
- Only hijack the database connection for requests in secondary regions
|
6
12
|
|
7
13
|
## 0.2.0
|
data/README.md
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
[](https://github.com/superfly/fly-ruby/actions/workflows/test.yml)
|
2
2
|
|
3
|
-
This gem contains helper code and Rack middleware for deploying Ruby web apps on [Fly.io](https://fly.io).
|
3
|
+
This gem contains helper code and Rack middleware for deploying Ruby web apps on [Fly.io](https://fly.io). It's designed to speed up apps by using region-local Postgresql replicas for database reads. See the blog post for more details:
|
4
4
|
|
5
|
-
|
5
|
+
https://fly.io/blog/run-ordinary-rails-apps-globally
|
6
6
|
|
7
7
|
## Speed up apps using region-local database replicas
|
8
8
|
|
@@ -49,7 +49,7 @@ Fly.configure do |c|
|
|
49
49
|
end
|
50
50
|
```
|
51
51
|
|
52
|
-
See [the source code](https://github.com/
|
52
|
+
See [the source code](https://github.com/superfly/fly-ruby/blob/main/lib/fly-ruby/configuration.rb) for defaults and available configuration options.
|
53
53
|
## Known issues
|
54
54
|
|
55
55
|
This middleware send all requests to the primary if you do something like update a user's database session on every GET request.
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Fly
|
2
4
|
class Configuration
|
3
5
|
# Set the region where this instance of the application is deployed
|
@@ -15,6 +17,7 @@ module Fly
|
|
15
17
|
attr_accessor :database_url_env_var
|
16
18
|
attr_accessor :database_host_env_var
|
17
19
|
attr_accessor :database_port_env_var
|
20
|
+
attr_accessor :redis_url_env_var
|
18
21
|
|
19
22
|
# Cookie written and read by this middleware storing a UNIX timestamp.
|
20
23
|
# Requests arriving before this timestamp will be replayed in the primary region.
|
@@ -25,33 +28,77 @@ module Fly
|
|
25
28
|
attr_accessor :replay_threshold_in_seconds
|
26
29
|
|
27
30
|
attr_accessor :database_url
|
31
|
+
attr_accessor :redis_url
|
32
|
+
|
33
|
+
# An array of string representations of exceptions that should trigger a replay
|
34
|
+
attr_accessor :replayable_exceptions
|
28
35
|
|
29
36
|
def initialize
|
30
37
|
self.primary_region = ENV["PRIMARY_REGION"]
|
31
38
|
self.current_region = ENV["FLY_REGION"]
|
32
39
|
self.replay_http_methods = ["POST", "PUT", "PATCH", "DELETE"]
|
33
40
|
self.database_url_env_var = "DATABASE_URL"
|
41
|
+
self.redis_url_env_var = "REDIS_URL"
|
34
42
|
self.database_host_env_var = "DATABASE_HOST"
|
35
43
|
self.database_port_env_var = "DATABASE_PORT"
|
36
44
|
self.replay_threshold_cookie = "fly-replay-threshold"
|
37
45
|
self.replay_threshold_in_seconds = 5
|
38
46
|
self.database_url = ENV[database_url_env_var]
|
47
|
+
self.redis_url = ENV[redis_url_env_var]
|
48
|
+
self.replayable_exceptions = ["SQLite3::CantOpenException", "PG::ReadOnlySqlTransaction"]
|
49
|
+
end
|
50
|
+
|
51
|
+
def replayable_exception_classes
|
52
|
+
@replayable_exception_classes ||= replayable_exceptions.collect {|ex| module_exists?(ex) }.compact
|
53
|
+
@replayable_exception_classes
|
54
|
+
end
|
55
|
+
|
56
|
+
def module_exists?(module_name)
|
57
|
+
mod = Module.const_get(module_name)
|
58
|
+
return mod
|
59
|
+
rescue NameError
|
60
|
+
nil
|
61
|
+
end
|
62
|
+
|
63
|
+
def database_uri
|
64
|
+
@database_uri ||= URI.parse(database_url)
|
65
|
+
@database_uri
|
39
66
|
end
|
40
67
|
|
41
|
-
def
|
42
|
-
|
43
|
-
|
68
|
+
def regional_database_url
|
69
|
+
uri = database_uri.dup
|
70
|
+
uri.host = regional_database_host
|
71
|
+
uri.to_s
|
72
|
+
end
|
73
|
+
|
74
|
+
def regional_database_host
|
75
|
+
"#{current_region}.#{database_uri.hostname}"
|
44
76
|
end
|
45
77
|
|
46
78
|
# Rails-compatible database configuration
|
47
79
|
def regional_database_config
|
48
80
|
{
|
49
|
-
|
50
|
-
|
51
|
-
|
81
|
+
:host => regional_database_host,
|
82
|
+
:port => 5433,
|
83
|
+
:adapter => "postgresql"
|
52
84
|
}
|
53
85
|
end
|
54
86
|
|
87
|
+
def redis_uri
|
88
|
+
@redis_uri ||= URI.parse(redis_url)
|
89
|
+
@redis_uri
|
90
|
+
end
|
91
|
+
|
92
|
+
def regional_redis_host
|
93
|
+
"#{current_region}.#{redis_uri.hostname}"
|
94
|
+
end
|
95
|
+
|
96
|
+
def regional_redis_url
|
97
|
+
uri = redis_uri.dup
|
98
|
+
uri.host = regional_redis_host
|
99
|
+
uri.to_s
|
100
|
+
end
|
101
|
+
|
55
102
|
def eligible_for_activation?
|
56
103
|
database_url && primary_region && current_region && web?
|
57
104
|
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fly
|
4
|
+
class Headers
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(env)
|
10
|
+
status, headers, body = @app.call(env)
|
11
|
+
response = Rack::Response.new(body, status, headers)
|
12
|
+
response.set_header('Fly-Region', ENV['FLY_REGION'])
|
13
|
+
response.finish
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/fly-ruby/railtie.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Fly::Railtie < Rails::Railtie
|
4
4
|
def hijack_database_connection
|
@@ -8,28 +8,23 @@ class Fly::Railtie < Rails::Railtie
|
|
8
8
|
# hooks for forking servers to work correctly.
|
9
9
|
if defined?(ActiveRecord)
|
10
10
|
config = ActiveRecord::Base.connection_db_config.configuration_hash
|
11
|
-
ActiveRecord::Base.establish_connection(config.merge(Fly.configuration.regional_database_config))
|
11
|
+
ActiveRecord::Base.establish_connection(config.symbolize_keys.merge(Fly.configuration.regional_database_config))
|
12
12
|
end
|
13
13
|
end
|
14
14
|
end
|
15
15
|
|
16
|
-
# Set useful headers for debugging
|
17
|
-
def set_debug_response_headers
|
18
|
-
return unless defined?(ApplicationController)
|
19
|
-
|
20
|
-
ApplicationController.send(:after_action) do
|
21
|
-
response.headers['Fly-Region'] = ENV['FLY_REGION']
|
22
|
-
response.headers['Fly-Database-Host'] = Fly.configuration.regional_database_config["host"]
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
16
|
initializer("fly.regional_database") do |app|
|
27
|
-
|
17
|
+
# Insert the request middleware high in the stack, but after static file delivery
|
18
|
+
app.config.middleware.insert_after ActionDispatch::Executor, Fly::Headers if Fly.configuration.web?
|
19
|
+
|
28
20
|
if Fly.configuration.eligible_for_activation?
|
29
|
-
app.config.middleware.insert_after
|
21
|
+
app.config.middleware.insert_after Fly::Headers, Fly::RegionalDatabase::ReplayableRequestMiddleware
|
22
|
+
# Insert the database exception handler at the bottom of the stack to take priority over other exception handlers
|
23
|
+
app.config.middleware.use Fly::RegionalDatabase::DbExceptionHandlerMiddleware
|
30
24
|
|
31
|
-
|
32
|
-
|
25
|
+
if Fly.configuration.in_secondary_region?
|
26
|
+
hijack_database_connection
|
27
|
+
end
|
33
28
|
elsif Fly.configuration.web?
|
34
29
|
puts "Warning: DATABASE_URL, PRIMARY_REGION and FLY_REGION must be set to activate the fly-ruby middleware. Middleware not loaded."
|
35
30
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'rake'
|
2
4
|
|
3
5
|
module Fly
|
@@ -5,39 +7,7 @@ module Fly
|
|
5
7
|
# multithreaded environments. Instead of using dirty tricks like using Object#dup,
|
6
8
|
# values are passed to methods.
|
7
9
|
|
8
|
-
|
9
|
-
def initialize(app)
|
10
|
-
@app = app
|
11
|
-
end
|
12
|
-
|
13
|
-
# Overwrite the database connection string environment variable
|
14
|
-
# to prefer connections to the regional replica.
|
15
|
-
#
|
16
|
-
# For Rails apps, this process will be repeated at middleware insertion time,
|
17
|
-
# to support situations where the database is already accessed by other
|
18
|
-
# initialization code. See Fly::Railtie.
|
19
|
-
|
20
|
-
def prefer_regional_database!
|
21
|
-
return if Fly.configuration.web?
|
22
|
-
|
23
|
-
uri = Fly.configuration.database_uri
|
24
|
-
|
25
|
-
ENV[Fly.configuration.database_url_env_var] = uri.to_s
|
26
|
-
ENV[Fly.configuration.database_host_env_var] = uri.hostname
|
27
|
-
ENV[Fly.configuration.database_port_env_var] = uri.port.to_s
|
28
|
-
end
|
29
|
-
|
30
|
-
def in_primary_region?
|
31
|
-
Fly.configuration.primary_region == Fly.configuration.current_region
|
32
|
-
end
|
33
|
-
|
34
|
-
def regional_database_url
|
35
|
-
end
|
36
|
-
|
37
|
-
def response_body
|
38
|
-
"<html>Replaying request in #{Fly.configuration.primary_region}</html>"
|
39
|
-
end
|
40
|
-
|
10
|
+
module RegionalDatabase
|
41
11
|
# Stop the current request and ask for it to be replayed in the primary region.
|
42
12
|
# Pass one of three states to the target region, to determine how to handle the request:
|
43
13
|
#
|
@@ -45,70 +15,84 @@ module Fly
|
|
45
15
|
# captured_write: A write was rejected by the database
|
46
16
|
# http_method: A non-idempotent HTTP method was replayed before hitting the application
|
47
17
|
# threshold: A recent write set a threshold during which all requests are replayed
|
48
|
-
|
49
|
-
def replay_in_primary_region!(state:)
|
18
|
+
|
19
|
+
def self.replay_in_primary_region!(state:)
|
50
20
|
res = Rack::Response.new(
|
51
|
-
|
21
|
+
"",
|
52
22
|
409,
|
53
|
-
{"
|
23
|
+
{"Fly-Replay" => "region=#{Fly.configuration.primary_region};state=#{state}"}
|
54
24
|
)
|
55
25
|
res.finish
|
56
26
|
end
|
57
27
|
|
58
|
-
|
59
|
-
|
60
|
-
|
28
|
+
class DbExceptionHandlerMiddleware
|
29
|
+
def initialize(app)
|
30
|
+
@app = app
|
31
|
+
end
|
61
32
|
|
62
|
-
|
63
|
-
|
33
|
+
def call(env)
|
34
|
+
exceptions = Fly.configuration.replayable_exception_classes
|
35
|
+
@app.call(env)
|
36
|
+
rescue *exceptions, ActiveRecord::RecordInvalid => e
|
37
|
+
if exceptions.any? {|ex| e.is_a?(ex) } || exceptions.any? { e&.cause&.is_a?(e) }
|
38
|
+
RegionalDatabase.replay_in_primary_region!(state: "captured_write")
|
39
|
+
else
|
40
|
+
raise e
|
41
|
+
end
|
42
|
+
end
|
64
43
|
end
|
65
44
|
|
66
|
-
|
67
|
-
|
68
|
-
|
45
|
+
class ReplayableRequestMiddleware
|
46
|
+
def initialize(app)
|
47
|
+
@app = app
|
48
|
+
end
|
69
49
|
|
70
|
-
|
71
|
-
|
50
|
+
def within_replay_threshold?(threshold)
|
51
|
+
threshold && (threshold.to_i - Time.now.to_i) > 0
|
52
|
+
end
|
72
53
|
|
73
|
-
|
74
|
-
|
75
|
-
# 1. Its HTTP method matches those configured for automatic replay (post/patch/put/delete by default).
|
76
|
-
# This approach should avoid potentially slow code execution - before_actions or other controller code -
|
77
|
-
# happening before a request reaches a database write.
|
78
|
-
# 2. It arrived before the threshold defined by the last write request. This threshold
|
79
|
-
# helps avoid the same client from missing its own write due to replication lag,
|
80
|
-
# like when a user adds to a todo list via XHR
|
81
|
-
|
82
|
-
if !in_primary_region?
|
83
|
-
if replayable_http_method?(request.request_method)
|
84
|
-
return replay_in_primary_region!(state: "http_method")
|
85
|
-
elsif within_replay_threshold?(request.cookies[Fly.configuration.replay_threshold_cookie])
|
86
|
-
return replay_in_primary_region!(state: "threshold")
|
87
|
-
end
|
54
|
+
def replayable_http_method?(http_method)
|
55
|
+
Fly.configuration.replay_http_methods.include?(http_method)
|
88
56
|
end
|
89
57
|
|
90
|
-
|
91
|
-
|
92
|
-
rescue ActiveRecord::StatementInvalid => e
|
93
|
-
if e.cause.is_a?(PG::ReadOnlySqlTransaction)
|
94
|
-
return replay_in_primary_region!(state: "captured_write")
|
95
|
-
else
|
96
|
-
raise e
|
97
|
-
end
|
58
|
+
def replay_request_state(header_value)
|
59
|
+
header_value&.slice(/(?:^|;)state=([^;]*)/, 1)
|
98
60
|
end
|
99
61
|
|
100
|
-
|
101
|
-
|
62
|
+
def call(env)
|
63
|
+
request = Rack::Request.new(env)
|
64
|
+
|
65
|
+
# Does this request satisfiy a condition for replaying in the primary region?
|
66
|
+
#
|
67
|
+
# 1. Its HTTP method matches those configured for automatic replay
|
68
|
+
# 2. It arrived before the threshold defined by the last write request.
|
69
|
+
# This threshold helps avoid the same client from missing its own
|
70
|
+
# write due to replication lag.
|
71
|
+
|
72
|
+
if Fly.configuration.in_secondary_region?
|
73
|
+
if replayable_http_method?(request.request_method)
|
74
|
+
return RegionalDatabase.replay_in_primary_region!(state: "http_method")
|
75
|
+
elsif within_replay_threshold?(request.cookies[Fly.configuration.replay_threshold_cookie])
|
76
|
+
return RegionalDatabase.replay_in_primary_region!(state: "threshold")
|
77
|
+
end
|
78
|
+
end
|
102
79
|
|
103
|
-
|
104
|
-
if replay_state && replay_state != "threshold"
|
105
|
-
response.set_cookie(
|
106
|
-
Fly.configuration.replay_threshold_cookie,
|
107
|
-
Time.now.to_i + Fly.configuration.replay_threshold_in_seconds
|
108
|
-
)
|
109
|
-
end
|
80
|
+
status, headers, body = @app.call(env)
|
110
81
|
|
111
|
-
|
82
|
+
response = Rack::Response.new(body, status, headers)
|
83
|
+
replay_state = replay_request_state(request.get_header("HTTP_FLY_REPLAY_SRC"))
|
84
|
+
|
85
|
+
# Request was replayed, but not by a threshold, so set a threshold within which
|
86
|
+
# all requests should be replayed to the primary region
|
87
|
+
if replay_state && replay_state != "threshold"
|
88
|
+
response.set_cookie(
|
89
|
+
Fly.configuration.replay_threshold_cookie,
|
90
|
+
Time.now.to_i + Fly.configuration.replay_threshold_in_seconds
|
91
|
+
)
|
92
|
+
end
|
93
|
+
|
94
|
+
response.finish
|
95
|
+
end
|
112
96
|
end
|
113
97
|
end
|
114
98
|
end
|
data/lib/fly-ruby/version.rb
CHANGED
data/lib/fly-ruby.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fly-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Joshua Sierles
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-08-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rack
|
@@ -41,6 +41,7 @@ files:
|
|
41
41
|
- fly-ruby.gemspec
|
42
42
|
- lib/fly-ruby.rb
|
43
43
|
- lib/fly-ruby/configuration.rb
|
44
|
+
- lib/fly-ruby/headers.rb
|
44
45
|
- lib/fly-ruby/railtie.rb
|
45
46
|
- lib/fly-ruby/regional_database.rb
|
46
47
|
- lib/fly-ruby/version.rb
|
@@ -63,7 +64,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
64
|
- !ruby/object:Gem::Version
|
64
65
|
version: '0'
|
65
66
|
requirements: []
|
66
|
-
rubygems_version: 3.2.
|
67
|
+
rubygems_version: 3.2.26
|
67
68
|
signing_key:
|
68
69
|
specification_version: 4
|
69
70
|
summary: Augment Ruby web apps for deployment in Fly.io
|