fly-ruby 0.1.1 → 0.3.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/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
|
[![Test](https://github.com/superfly/fly-ruby/actions/workflows/test.yml/badge.svg)](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
|