fly-ruby 0.1.1 → 0.3.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/.github/workflows/test.yml +27 -2
- data/CHANGELOG.md +17 -0
- data/Gemfile +7 -1
- data/README.md +12 -10
- data/Rakefile +10 -1
- data/lib/fly-ruby/configuration.rb +63 -3
- data/lib/fly-ruby/railtie.rb +32 -2
- data/lib/fly-ruby/regional_database.rb +61 -90
- data/lib/fly-ruby/version.rb +2 -2
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 506556ce79da83a817f5a6a56b336328912730ace3fb26d10ed0d7e97f971abc
|
4
|
+
data.tar.gz: c2c7ceb044a6a9054da36ae775a0693458e91dac271b5d341666995dd59373b5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d7a79ba9b2a2db74515bdce0c606c99072d292813b5ef34c8963d44bcc0366fe0230bc784dcb8e489614ef1dd33ebb447d7cb215fd7f475c0fc00d51487446cf
|
7
|
+
data.tar.gz: 38e26c8853bde15845b1cd1c34bcff68f5f87db5a8e726af4451329619b54f3c4dfa26ffd83832e24ea0a743401b1e28c011316ab78b08c60cc76e42022bec58
|
data/.github/workflows/test.yml
CHANGED
@@ -7,12 +7,35 @@ jobs:
|
|
7
7
|
strategy:
|
8
8
|
matrix:
|
9
9
|
include:
|
10
|
-
- { os: ubuntu-latest, ruby_version: 2.4 }
|
11
10
|
- { os: ubuntu-latest, ruby_version: 2.5 }
|
12
11
|
- { os: ubuntu-latest, ruby_version: 2.6 }
|
13
12
|
- { os: ubuntu-latest, ruby_version: 2.7 }
|
14
13
|
- { os: ubuntu-latest, ruby_version: '3.0' }
|
15
|
-
|
14
|
+
services:
|
15
|
+
# label used to access the service container
|
16
|
+
postgres:
|
17
|
+
# Docker Hub image
|
18
|
+
image: postgres:latest
|
19
|
+
# service environment variables
|
20
|
+
# `POSTGRES_HOST` is `postgres`
|
21
|
+
env:
|
22
|
+
# optional (defaults to `postgres`)
|
23
|
+
POSTGRES_DB: fly_ruby_test
|
24
|
+
# required
|
25
|
+
POSTGRES_PASSWORD: postgres_password
|
26
|
+
# optional (defaults to `5432`)
|
27
|
+
POSTGRES_PORT: 5432
|
28
|
+
# optional (defaults to `postgres`)
|
29
|
+
POSTGRES_USER: postgres_user
|
30
|
+
ports:
|
31
|
+
# maps tcp port 5432 on service container to the host
|
32
|
+
- 5432:5432
|
33
|
+
# set health checks to wait until postgres has started
|
34
|
+
options: >-
|
35
|
+
--health-cmd pg_isready
|
36
|
+
--health-interval 10s
|
37
|
+
--health-timeout 5s
|
38
|
+
--health-retries 5
|
16
39
|
steps:
|
17
40
|
- name: Setup Ruby, JRuby and TruffleRuby
|
18
41
|
uses: ruby/setup-ruby@v1.75.0
|
@@ -22,6 +45,8 @@ jobs:
|
|
22
45
|
- name: Checkout code
|
23
46
|
uses: actions/checkout@v2
|
24
47
|
- name: Run tests
|
48
|
+
env:
|
49
|
+
DATABASE_USER: postgres_user
|
25
50
|
run: |
|
26
51
|
bundle install --jobs 4 --retry 3
|
27
52
|
rake
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
## 0.2.1
|
2
|
+
|
3
|
+
### Bug fixes
|
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
|
+
|
11
|
+
- Only hijack the database connection for requests in secondary regions
|
12
|
+
|
13
|
+
## 0.2.0
|
14
|
+
|
15
|
+
### Features
|
16
|
+
|
17
|
+
- Add `Fly-Region` and `Fly-Database-Host` response headers for easier debugging
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,23 +1,25 @@
|
|
1
1
|
[](https://github.com/superfly/fly-ruby/actions/workflows/test.yml)
|
2
2
|
|
3
|
-
|
3
|
+
This gem contains helper code and Rack middleware for deploying Ruby web apps on [Fly.io](https://fly.io). Supported features:
|
4
4
|
|
5
|
-
|
5
|
+
* Speed up apps by using region-local Postgresql replicas for database reads
|
6
6
|
|
7
|
-
##
|
7
|
+
## Speed up apps using region-local database replicas
|
8
8
|
|
9
|
-
|
9
|
+
Fly's [cross-region private networking](https://fly.io/docs/reference/privatenetwork/) makes it easy to run database replicas [alongside your app instances in multiple regions](https://fly.io/docs/getting-started/multi-region-databases/). These replicas can be used for faster reads and application performance.
|
10
10
|
|
11
|
-
|
11
|
+
Writes, however, will be slow if performed across regions. Fly allows web apps to specify that a request be *replayed*, at the routing layer, in another region.
|
12
12
|
|
13
|
-
This
|
14
|
-
with any Rack-compatible Ruby framework.
|
13
|
+
This gem includes Rack middleware to automatically route such requests to the primary region. It's designed should work with any Rack-compatible Ruby framework.
|
15
14
|
|
16
15
|
Currently, it does this by:
|
17
16
|
|
18
17
|
* modifying the `DATABASE_URL` to point apps to their local regional replica
|
19
18
|
* replaying non-idempotent (post/put/patch/delete) requests in the primary region
|
20
|
-
* catching Postgresql exceptions caused by writes to a read-only replica, and
|
19
|
+
* catching Postgresql exceptions caused by writes to a read-only replica, and asking for
|
20
|
+
these requests to be replayed in the primary region
|
21
|
+
* replaying all requests within a time threshold after a write, to avoid users seeing
|
22
|
+
their own stale data due to replication lag
|
21
23
|
|
22
24
|
## Requirements
|
23
25
|
|
@@ -35,7 +37,7 @@ Add to your Gemfile and `bundle install`:
|
|
35
37
|
|
36
38
|
`gem "fly-ruby"`
|
37
39
|
|
38
|
-
If you're on Rails, the middleware will insert itself automatically
|
40
|
+
If you're on Rails, the middleware will insert itself automatically, and attempt to reconnect the database.
|
39
41
|
|
40
42
|
## Configuration
|
41
43
|
|
@@ -52,7 +54,7 @@ See [the source code](https://github.com/soupedup/fly-rails/blob/main/lib/fly-ra
|
|
52
54
|
|
53
55
|
This middleware send all requests to the primary if you do something like update a user's database session on every GET request.
|
54
56
|
|
55
|
-
If your replica becomes writeable for some reason, your
|
57
|
+
If your replica becomes writeable for some reason, your cluster may get out of sync.
|
56
58
|
|
57
59
|
## TODO
|
58
60
|
|
data/Rakefile
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
2
|
require "rake/testtask"
|
3
|
+
require_relative "lib/fly-ruby/version"
|
3
4
|
|
4
5
|
Rake::TestTask.new do |t|
|
5
6
|
t.libs << "test"
|
@@ -10,7 +11,15 @@ end
|
|
10
11
|
desc "Run tests"
|
11
12
|
task default: :test
|
12
13
|
|
13
|
-
|
14
14
|
task :top do
|
15
15
|
puts Rake.application.top_level_tasks
|
16
16
|
end
|
17
|
+
|
18
|
+
task :publish do
|
19
|
+
version = Fly::VERSION
|
20
|
+
puts "Publishing fly-ruby #{version}..."
|
21
|
+
sh "git tag -f v#{version}"
|
22
|
+
sh "gem build"
|
23
|
+
sh "gem push fly-ruby-#{version}.gem"
|
24
|
+
sh "git push --tags"
|
25
|
+
end
|
@@ -15,6 +15,7 @@ module Fly
|
|
15
15
|
attr_accessor :database_url_env_var
|
16
16
|
attr_accessor :database_host_env_var
|
17
17
|
attr_accessor :database_port_env_var
|
18
|
+
attr_accessor :redis_url_env_var
|
18
19
|
|
19
20
|
# Cookie written and read by this middleware storing a UNIX timestamp.
|
20
21
|
# Requests arriving before this timestamp will be replayed in the primary region.
|
@@ -24,23 +25,82 @@ module Fly
|
|
24
25
|
# primary region after a successful write replay
|
25
26
|
attr_accessor :replay_threshold_in_seconds
|
26
27
|
|
28
|
+
attr_accessor :database_url
|
29
|
+
attr_accessor :redis_url
|
30
|
+
|
27
31
|
def initialize
|
28
32
|
self.primary_region = ENV["PRIMARY_REGION"]
|
29
33
|
self.current_region = ENV["FLY_REGION"]
|
30
34
|
self.replay_http_methods = ["POST", "PUT", "PATCH", "DELETE"]
|
31
35
|
self.database_url_env_var = "DATABASE_URL"
|
36
|
+
self.redis_url_env_var = "REDIS_URL"
|
32
37
|
self.database_host_env_var = "DATABASE_HOST"
|
33
38
|
self.database_port_env_var = "DATABASE_PORT"
|
34
39
|
self.replay_threshold_cookie = "fly-replay-threshold"
|
35
40
|
self.replay_threshold_in_seconds = 5
|
41
|
+
self.database_url = ENV[database_url_env_var]
|
42
|
+
self.redis_url = ENV[redis_url_env_var]
|
43
|
+
end
|
44
|
+
|
45
|
+
def database_uri
|
46
|
+
@database_uri ||= URI.parse(database_url)
|
47
|
+
@database_uri
|
48
|
+
end
|
49
|
+
|
50
|
+
def regional_database_url
|
51
|
+
uri = database_uri.dup
|
52
|
+
uri.host = regional_database_host
|
53
|
+
uri.to_s
|
54
|
+
end
|
55
|
+
|
56
|
+
def regional_database_host
|
57
|
+
"#{current_region}.#{database_uri.hostname}"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Rails-compatible database configuration
|
61
|
+
def regional_database_config
|
62
|
+
{
|
63
|
+
"host" => regional_database_host,
|
64
|
+
"port" => 5433,
|
65
|
+
"adapter" => "postgresql"
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
def redis_uri
|
70
|
+
@redis_uri ||= URI.parse(redis_url)
|
71
|
+
@redis_uri
|
36
72
|
end
|
37
73
|
|
38
|
-
def
|
39
|
-
|
74
|
+
def regional_redis_host
|
75
|
+
"#{current_region}.#{redis_uri.hostname}"
|
76
|
+
end
|
77
|
+
|
78
|
+
def regional_redis_url
|
79
|
+
uri = redis_uri.dup
|
80
|
+
uri.host = regional_redis_host
|
81
|
+
uri.to_s
|
40
82
|
end
|
41
83
|
|
42
84
|
def eligible_for_activation?
|
43
|
-
database_url && primary_region && current_region
|
85
|
+
database_url && primary_region && current_region && web?
|
86
|
+
end
|
87
|
+
|
88
|
+
def in_secondary_region?
|
89
|
+
primary_region && primary_region != current_region
|
90
|
+
end
|
91
|
+
|
92
|
+
# Is the current process a Rails console?
|
93
|
+
def console?
|
94
|
+
defined?(::Rails::Console) && $stdout.isatty && $stdin.isatty
|
95
|
+
end
|
96
|
+
|
97
|
+
# Is the current process a rake task?
|
98
|
+
def rake_task?
|
99
|
+
defined?(::Rake) && !Rake.application.top_level_tasks.empty?
|
100
|
+
end
|
101
|
+
|
102
|
+
def web?
|
103
|
+
!console? && !rake_task?
|
44
104
|
end
|
45
105
|
end
|
46
106
|
end
|
data/lib/fly-ruby/railtie.rb
CHANGED
@@ -1,8 +1,38 @@
|
|
1
1
|
class Fly::Railtie < Rails::Railtie
|
2
|
+
def hijack_database_connection
|
3
|
+
ActiveSupport::Reloader.to_prepare do
|
4
|
+
# If we already have a database connection when this initializer runs,
|
5
|
+
# we should reconnect to the region-local database. This may need some additional
|
6
|
+
# hooks for forking servers to work correctly.
|
7
|
+
if defined?(ActiveRecord)
|
8
|
+
config = ActiveRecord::Base.connection_db_config.configuration_hash
|
9
|
+
ActiveRecord::Base.establish_connection(config.merge(Fly.configuration.regional_database_config))
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# Set useful headers for debugging
|
15
|
+
def set_debug_response_headers
|
16
|
+
ActiveSupport::Reloader.to_prepare do
|
17
|
+
ApplicationController.send(:after_action) do
|
18
|
+
response.headers['Fly-Region'] = ENV['FLY_REGION']
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
2
23
|
initializer("fly.regional_database") do |app|
|
24
|
+
set_debug_response_headers if Fly.configuration.web?
|
25
|
+
|
3
26
|
if Fly.configuration.eligible_for_activation?
|
4
|
-
|
5
|
-
|
27
|
+
# Insert the request interceptor high in the stack, but after static file delivery
|
28
|
+
app.config.middleware.insert_after ActionDispatch::Executor, Fly::RegionalDatabase::ReplayableRequestMiddleware
|
29
|
+
# Insert the database exception handler at the bottom of the stack to take priority over other exception handlers
|
30
|
+
app.config.middleware.use Fly::RegionalDatabase::DbExceptionHandlerMiddleware
|
31
|
+
|
32
|
+
if Fly.configuration.in_secondary_region?
|
33
|
+
hijack_database_connection
|
34
|
+
end
|
35
|
+
elsif Fly.configuration.web?
|
6
36
|
puts "Warning: DATABASE_URL, PRIMARY_REGION and FLY_REGION must be set to activate the fly-ruby middleware. Middleware not loaded."
|
7
37
|
end
|
8
38
|
end
|
@@ -5,49 +5,7 @@ module Fly
|
|
5
5
|
# multithreaded environments. Instead of using dirty tricks like using Object#dup,
|
6
6
|
# values are passed to methods.
|
7
7
|
|
8
|
-
|
9
|
-
def initialize(app)
|
10
|
-
@app = app
|
11
|
-
prefer_regional_database! unless in_primary_region?
|
12
|
-
end
|
13
|
-
|
14
|
-
def console?
|
15
|
-
defined?(::Rails::Console) && $stdout.isatty && $stdin.isatty
|
16
|
-
end
|
17
|
-
|
18
|
-
def rake_task?
|
19
|
-
defined?(::Rake) && !Rake.application.top_level_tasks.empty?
|
20
|
-
end
|
21
|
-
|
22
|
-
# Overwrite the primary database URL with that of the regional replica
|
23
|
-
def prefer_regional_database!
|
24
|
-
# Don't override the database if migrations are running
|
25
|
-
return if console? || rake_task?
|
26
|
-
|
27
|
-
uri = URI.parse(Fly.configuration.database_url)
|
28
|
-
hostname = "#{Fly.configuration.current_region}.#{uri.hostname}"
|
29
|
-
port = 5433
|
30
|
-
|
31
|
-
uri.hostname = hostname
|
32
|
-
uri.port = port
|
33
|
-
uri.to_s
|
34
|
-
|
35
|
-
ENV[Fly.configuration.database_url_env_var] = uri.to_s
|
36
|
-
ENV[Fly.configuration.database_host_env_var] = hostname
|
37
|
-
ENV[Fly.configuration.database_port_env_var] = port.to_s
|
38
|
-
end
|
39
|
-
|
40
|
-
def in_primary_region?
|
41
|
-
Fly.configuration.primary_region == Fly.configuration.current_region
|
42
|
-
end
|
43
|
-
|
44
|
-
def regional_database_url
|
45
|
-
end
|
46
|
-
|
47
|
-
def response_body
|
48
|
-
"<html>Replaying request in #{Fly.configuration.primary_region}</html>"
|
49
|
-
end
|
50
|
-
|
8
|
+
module RegionalDatabase
|
51
9
|
# Stop the current request and ask for it to be replayed in the primary region.
|
52
10
|
# Pass one of three states to the target region, to determine how to handle the request:
|
53
11
|
#
|
@@ -55,70 +13,83 @@ module Fly
|
|
55
13
|
# captured_write: A write was rejected by the database
|
56
14
|
# http_method: A non-idempotent HTTP method was replayed before hitting the application
|
57
15
|
# threshold: A recent write set a threshold during which all requests are replayed
|
58
|
-
|
59
|
-
def replay_in_primary_region!(state:)
|
16
|
+
|
17
|
+
def self.replay_in_primary_region!(state:)
|
60
18
|
res = Rack::Response.new(
|
61
|
-
|
19
|
+
"",
|
62
20
|
409,
|
63
|
-
{"
|
21
|
+
{"Fly-Replay" => "region=#{Fly.configuration.primary_region};state=#{state}"}
|
64
22
|
)
|
65
23
|
res.finish
|
66
24
|
end
|
67
25
|
|
68
|
-
|
69
|
-
|
70
|
-
|
26
|
+
class DbExceptionHandlerMiddleware
|
27
|
+
def initialize(app)
|
28
|
+
@app = app
|
29
|
+
end
|
71
30
|
|
72
|
-
|
73
|
-
|
31
|
+
def call(env)
|
32
|
+
@app.call(env)
|
33
|
+
rescue PG::ReadOnlySqlTransaction, ActiveRecord::StatementInvalid => e
|
34
|
+
if e.is_a?(PG::ReadOnlySqlTransaction) || e&.cause&.is_a?(PG::ReadOnlySqlTransaction)
|
35
|
+
RegionalDatabase.replay_in_primary_region!(state: "captured_write")
|
36
|
+
else
|
37
|
+
raise e
|
38
|
+
end
|
39
|
+
end
|
74
40
|
end
|
75
41
|
|
76
|
-
|
77
|
-
|
78
|
-
|
42
|
+
class ReplayableRequestMiddleware
|
43
|
+
def initialize(app)
|
44
|
+
@app = app
|
45
|
+
end
|
79
46
|
|
80
|
-
|
81
|
-
|
47
|
+
def within_replay_threshold?(threshold)
|
48
|
+
threshold && (threshold.to_i - Time.now.to_i) > 0
|
49
|
+
end
|
82
50
|
|
83
|
-
|
84
|
-
|
85
|
-
# 1. Its HTTP method matches those configured for automatic replay (post/patch/put/delete by default).
|
86
|
-
# This approach should avoid potentially slow code execution - before_actions or other controller code -
|
87
|
-
# happening before a request reaches a database write.
|
88
|
-
# 2. It arrived before the threshold defined by the last write request. This threshold
|
89
|
-
# helps avoid the same client from missing its own write due to replication lag,
|
90
|
-
# like when a user adds to a todo list via XHR
|
91
|
-
|
92
|
-
if !in_primary_region?
|
93
|
-
if replayable_http_method?(request.request_method)
|
94
|
-
return replay_in_primary_region!(state: "http_method")
|
95
|
-
elsif within_replay_threshold?(request.cookies[Fly.configuration.replay_threshold_cookie])
|
96
|
-
return replay_in_primary_region!(state: "threshold")
|
97
|
-
end
|
51
|
+
def replayable_http_method?(http_method)
|
52
|
+
Fly.configuration.replay_http_methods.include?(http_method)
|
98
53
|
end
|
99
54
|
|
100
|
-
|
101
|
-
|
102
|
-
rescue ActiveRecord::StatementInvalid => e
|
103
|
-
if e.cause.is_a?(PG::ReadOnlySqlTransaction)
|
104
|
-
return replay_in_primary_region!(state: "captured_write")
|
105
|
-
else
|
106
|
-
raise e
|
107
|
-
end
|
55
|
+
def replay_request_state(header_value)
|
56
|
+
header_value&.scan(/(.*?)=(.*?)($|;)/)&.detect { |v| v[0] == "state" }&.at(1)
|
108
57
|
end
|
109
58
|
|
110
|
-
|
111
|
-
|
59
|
+
def call(env)
|
60
|
+
request = Rack::Request.new(env)
|
61
|
+
|
62
|
+
# Does this request satisfiy a condition for replaying in the primary region?
|
63
|
+
#
|
64
|
+
# 1. Its HTTP method matches those configured for automatic replay
|
65
|
+
# 2. It arrived before the threshold defined by the last write request.
|
66
|
+
# This threshold helps avoid the same client from missing its own
|
67
|
+
# write due to replication lag.
|
68
|
+
|
69
|
+
if Fly.configuration.in_secondary_region?
|
70
|
+
if replayable_http_method?(request.request_method)
|
71
|
+
return RegionalDatabase.replay_in_primary_region!(state: "http_method")
|
72
|
+
elsif within_replay_threshold?(request.cookies[Fly.configuration.replay_threshold_cookie])
|
73
|
+
return RegionalDatabase.replay_in_primary_region!(state: "threshold")
|
74
|
+
end
|
75
|
+
end
|
112
76
|
|
113
|
-
|
114
|
-
|
115
|
-
response.
|
116
|
-
|
117
|
-
Time.now.to_i + Fly.configuration.replay_threshold_in_seconds
|
118
|
-
)
|
119
|
-
end
|
77
|
+
status, headers, body = @app.call(env)
|
78
|
+
|
79
|
+
response = Rack::Response.new(body, status, headers)
|
80
|
+
replay_state = replay_request_state(request.get_header("HTTP_FLY_REPLAY_SRC"))
|
120
81
|
|
121
|
-
|
82
|
+
# Request was replayed, but not by a threshold, so set a threshold within which
|
83
|
+
# all requests should be replayed to the primary region
|
84
|
+
if replay_state && replay_state != "threshold"
|
85
|
+
response.set_cookie(
|
86
|
+
Fly.configuration.replay_threshold_cookie,
|
87
|
+
Time.now.to_i + Fly.configuration.replay_threshold_in_seconds
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
response.finish
|
92
|
+
end
|
122
93
|
end
|
123
94
|
end
|
124
95
|
end
|
data/lib/fly-ruby/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
|
2
|
-
VERSION = "0.
|
1
|
+
module Fly
|
2
|
+
VERSION = "0.3.0"
|
3
3
|
end
|
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.3.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: 2021-07-
|
11
|
+
date: 2021-07-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rack
|
@@ -33,6 +33,7 @@ extra_rdoc_files: []
|
|
33
33
|
files:
|
34
34
|
- ".github/workflows/test.yml"
|
35
35
|
- ".gitignore"
|
36
|
+
- CHANGELOG.md
|
36
37
|
- Gemfile
|
37
38
|
- LICENSE
|
38
39
|
- README.md
|