fly-ruby 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +30 -0
- data/.gitignore +6 -0
- data/Gemfile +5 -0
- data/LICENSE +11 -0
- data/README.md +62 -0
- data/Rakefile +11 -0
- data/fly-ruby.gemspec +17 -0
- data/lib/fly-ruby.rb +33 -0
- data/lib/fly-ruby/configuration.rb +46 -0
- data/lib/fly-ruby/railtie.rb +9 -0
- data/lib/fly-ruby/regional_database.rb +111 -0
- metadata +68 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 22dd71b806c6b21ec539f8fbeaf046a502134c4fcff78c9294f32602ceb82760
|
4
|
+
data.tar.gz: a8840d2201bbc30175c36d30ed98833415a3621b42282c5bec8d787690a01f25
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c1b8977226ee069be065c7b8baad3b242dfbe47560854ff3d1f20a6b02d95f3394a82f70fac4fb63c051387d5fb51cd16fc68b8f7ccf6d6323512e43c39559a0
|
7
|
+
data.tar.gz: 437c3d32c3498508d98cdc7ab96482260c93f367a754038985d124f1ffb05154cf4a3424650a0fb974d7f8c314188e9403556e8c7f00e150db7b5430b689b52b
|
@@ -0,0 +1,30 @@
|
|
1
|
+
on: [push, pull_request]
|
2
|
+
name: Test
|
3
|
+
jobs:
|
4
|
+
test:
|
5
|
+
defaults:
|
6
|
+
run:
|
7
|
+
working-directory: fly-ruby
|
8
|
+
name: Test on ruby ${{ matrix.ruby_version }} with options - ${{ toJson(matrix.options) }}
|
9
|
+
runs-on: ${{ matrix.os }}
|
10
|
+
strategy:
|
11
|
+
matrix:
|
12
|
+
include:
|
13
|
+
- { os: ubuntu-latest, ruby_version: 2.4 }
|
14
|
+
- { os: ubuntu-latest, ruby_version: 2.5 }
|
15
|
+
- { os: ubuntu-latest, ruby_version: 2.6 }
|
16
|
+
- { os: ubuntu-latest, ruby_version: 2.7 }
|
17
|
+
- { os: ubuntu-latest, ruby_version: '3.0' }
|
18
|
+
- { os: ubuntu-latest, ruby_version: jruby }
|
19
|
+
steps:
|
20
|
+
- name: Setup Ruby, JRuby and TruffleRuby
|
21
|
+
uses: ruby/setup-ruby@v1.75.0
|
22
|
+
with:
|
23
|
+
bundler: 1
|
24
|
+
ruby-version: ${{ matrix.ruby_version }}
|
25
|
+
- name: Checkout code
|
26
|
+
uses: actions/checkout@v2
|
27
|
+
- name: Run tests
|
28
|
+
run: |
|
29
|
+
bundle install --jobs 4 --retry 3
|
30
|
+
rake
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
Copyright 2021 Joshua Sierles
|
2
|
+
|
3
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
4
|
+
|
5
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
6
|
+
|
7
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
8
|
+
|
9
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
10
|
+
|
11
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,62 @@
|
|
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
|
+
|
3
|
+
# Augment Ruby web apps on Fly.io
|
4
|
+
|
5
|
+
[Fly.io](https://fly.io) offers a number of native features that can improve the perceived speed and observability of web applications with minimal configuration. This gem automates some of the work required to take advantage of these features.
|
6
|
+
|
7
|
+
## Regional replicas
|
8
|
+
|
9
|
+
Running database replicas alongside your apps in multiple regions [is quick and easy with Fly's Postgresql cluster](https://fly.io/docs/getting-started/multi-region-databases/). This can increase the perceived speed of read-heavy applications.
|
10
|
+
|
11
|
+
The catch: in most primary/replica setups, you have one writeable primary located in a specific region. Fly solves this by allowing requests to be *replyed*, at the routing layer, in another region.
|
12
|
+
|
13
|
+
This repository includes the `fly-ruby` gem which will utomatcally route requests that write to the database to the primary region. It should work
|
14
|
+
with any Rack-compatible Ruby framework.
|
15
|
+
|
16
|
+
Currently, it does this by:
|
17
|
+
|
18
|
+
* modifying the `DATABASE_URL` to point apps to their local regional replica
|
19
|
+
* 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 replaying these requests in the primary region
|
21
|
+
|
22
|
+
## Requirements
|
23
|
+
|
24
|
+
You should have [setup a postgres cluster](https://fly.io/docs/getting-started/multi-region-databases/) on Fly. Then:
|
25
|
+
|
26
|
+
* ensure that your Postgresql and application regions match up
|
27
|
+
* ensure that no backup regions are assigned to your application
|
28
|
+
* attach the Postgres cluster to your application with `fly postgres attach`
|
29
|
+
|
30
|
+
Finally, set the `PRIMARY_REGION` environment variable in your app `fly.toml` to match the primary database region.
|
31
|
+
|
32
|
+
## Installation
|
33
|
+
|
34
|
+
Add to your Gemfile and `bundle install`:
|
35
|
+
|
36
|
+
`gem "fly-ruby"`
|
37
|
+
|
38
|
+
If you're on Rails, the middleware will insert itself automatically at the top of the Rack middleware stack.
|
39
|
+
|
40
|
+
## Configuration
|
41
|
+
|
42
|
+
Most values used by this middleware are configurable. On Rails, this might go in an initializer like `config/initializers/fly.rb`
|
43
|
+
|
44
|
+
```
|
45
|
+
Fly.configure do |c|
|
46
|
+
c.replay_threshold_in_seconds = 10
|
47
|
+
end
|
48
|
+
```
|
49
|
+
|
50
|
+
See [the source code](https://github.com/soupedup/fly-rails/blob/main/lib/fly-rails/configuration.rb) for defaults and available configuration options.
|
51
|
+
## Known issues
|
52
|
+
|
53
|
+
This middleware send all requests to the primary if you do something like update a user's database session on every GET request.
|
54
|
+
|
55
|
+
If your replica becomes writeable for some reason, your custer may get out of sync.
|
56
|
+
|
57
|
+
## TODO
|
58
|
+
|
59
|
+
Here are some ideas for improving this gem.
|
60
|
+
|
61
|
+
* Add a helper to invoke ActiveJob, and possibly AR read/write split support, to send GET-originated writes to the primary database in the background
|
62
|
+
|
data/Rakefile
ADDED
data/fly-ruby.gemspec
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require_relative 'lib/fly-ruby/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "fly-ruby"
|
5
|
+
spec.version = Fly::VERSION
|
6
|
+
spec.authors = ["Joshua Sierles"]
|
7
|
+
spec.homepage = "https://github.com/superfly/fly-ruby"
|
8
|
+
spec.summary = "Augment Ruby web apps for deployment in Fly.io"
|
9
|
+
spec.description = "Automate the work requied to run Ruby apps against region-local databases on Fly.io"
|
10
|
+
spec.email = "joshua@hey.com"
|
11
|
+
spec.licenses = "BSD-3-Clause"
|
12
|
+
spec.platform = Gem::Platform::RUBY
|
13
|
+
spec.required_ruby_version = ">= 2.4"
|
14
|
+
spec.files = `git ls-files | grep -Ev '^(test)'`.split("\n")
|
15
|
+
|
16
|
+
spec.add_dependency "rack", "~> 2.0"
|
17
|
+
end
|
data/lib/fly-ruby.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require_relative "fly-ruby/configuration"
|
2
|
+
require_relative "fly-ruby/regional_database"
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
if defined?(::Rails)
|
6
|
+
require_relative "fly-ruby/railtie"
|
7
|
+
end
|
8
|
+
|
9
|
+
module Fly
|
10
|
+
class << self
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
def instance
|
14
|
+
@instance ||= Instance.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def_delegators :instance, :configuration, :configuration=, :configure
|
18
|
+
end
|
19
|
+
|
20
|
+
class Instance
|
21
|
+
attr_accessor :configuration
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
self.configuration = Fly::Configuration.new
|
25
|
+
end
|
26
|
+
|
27
|
+
def configure
|
28
|
+
configuration = Fly::Configuration.new
|
29
|
+
yield(configuration) if block_given?
|
30
|
+
self.configuration = configuration
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Fly
|
2
|
+
class Configuration
|
3
|
+
# Set the region where this instance of the application is deployed
|
4
|
+
attr_accessor :current_region
|
5
|
+
|
6
|
+
# Set the region where the primary database lives, i.e "ams"
|
7
|
+
attr_accessor :primary_region
|
8
|
+
|
9
|
+
# Automatically replay these HTTP methods in the primary region
|
10
|
+
attr_accessor :replay_http_methods
|
11
|
+
|
12
|
+
# Environment variables related to the database connection.
|
13
|
+
# These get by this middleware in secondary regions, so they must be interpolated
|
14
|
+
# rather than defined directly in the configuration.
|
15
|
+
attr_accessor :database_url_env_var
|
16
|
+
attr_accessor :database_host_env_var
|
17
|
+
attr_accessor :database_port_env_var
|
18
|
+
|
19
|
+
# Cookie written and read by this middleware storing a UNIX timestamp.
|
20
|
+
# Requests arriving before this timestamp will be replayed in the primary region.
|
21
|
+
attr_accessor :replay_threshold_cookie
|
22
|
+
|
23
|
+
# How long, in seconds, should all requests from the same client be replayed in the
|
24
|
+
# primary region after a successful write replay
|
25
|
+
attr_accessor :replay_threshold_in_seconds
|
26
|
+
|
27
|
+
def initialize
|
28
|
+
self.primary_region = ENV["PRIMARY_REGION"]
|
29
|
+
self.current_region = ENV["FLY_REGION"]
|
30
|
+
self.replay_http_methods = ["POST", "PUT", "PATCH", "DELETE"]
|
31
|
+
self.database_url_env_var = "DATABASE_URL"
|
32
|
+
self.database_host_env_var = "DATABASE_HOST"
|
33
|
+
self.database_port_env_var = "DATABASE_PORT"
|
34
|
+
self.replay_threshold_cookie = "fly-replay-threshold"
|
35
|
+
self.replay_threshold_in_seconds = 5
|
36
|
+
end
|
37
|
+
|
38
|
+
def database_url
|
39
|
+
ENV[database_url_env_var]
|
40
|
+
end
|
41
|
+
|
42
|
+
def eligible_for_activation?
|
43
|
+
database_url && primary_region && current_region
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
class Fly::Railtie < Rails::Railtie
|
2
|
+
initializer("fly.regional_database") do |app|
|
3
|
+
if Fly.configuration.eligible_for_activation?
|
4
|
+
app.config.middleware.insert_after ActionDispatch::Executor, Fly::RegionalDatabase
|
5
|
+
elsif !ENV["TESTING"]
|
6
|
+
puts "Warning: DATABASE_URL, PRIMARY_REGION and FLY_REGION must be set to activate the fly-ruby middleware. Middleware not loaded."
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module Fly
|
2
|
+
# Note that using instance variables in Rack middleware is considered a poor practice in
|
3
|
+
# multithreaded environments. Instead of using dirty tricks like using Object#dup,
|
4
|
+
# values are passed to methods.
|
5
|
+
|
6
|
+
class RegionalDatabase
|
7
|
+
def initialize(app)
|
8
|
+
@app = app
|
9
|
+
prefer_regional_database! unless in_primary_region?
|
10
|
+
end
|
11
|
+
|
12
|
+
# Overwrite the primary database URL with that of the regional replica
|
13
|
+
def prefer_regional_database!
|
14
|
+
uri = URI.parse(Fly.configuration.database_url)
|
15
|
+
hostname = "#{Fly.configuration.current_region}.#{uri.hostname}"
|
16
|
+
port = 5433
|
17
|
+
|
18
|
+
uri.hostname = hostname
|
19
|
+
uri.port = port
|
20
|
+
uri.to_s
|
21
|
+
|
22
|
+
ENV[Fly.configuration.database_url_env_var] = uri.to_s
|
23
|
+
ENV[Fly.configuration.database_host_env_var] = hostname
|
24
|
+
ENV[Fly.configuration.database_port_env_var] = port.to_s
|
25
|
+
end
|
26
|
+
|
27
|
+
def in_primary_region?
|
28
|
+
Fly.configuration.primary_region == Fly.configuration.current_region
|
29
|
+
end
|
30
|
+
|
31
|
+
def regional_database_url
|
32
|
+
end
|
33
|
+
|
34
|
+
def response_body
|
35
|
+
"<html>Replaying request in #{Fly.configuration.primary_region}</html>"
|
36
|
+
end
|
37
|
+
|
38
|
+
# Stop the current request and ask for it to be replayed in the primary region.
|
39
|
+
# Pass one of three states to the target region, to determine how to handle the request:
|
40
|
+
#
|
41
|
+
# Possible states: captured_write, http_method, threshold
|
42
|
+
# captured_write: A write was rejected by the database
|
43
|
+
# http_method: A non-idempotent HTTP method was replayed before hitting the application
|
44
|
+
# threshold: A recent write set a threshold during which all requests are replayed
|
45
|
+
#
|
46
|
+
def replay_in_primary_region!(state:)
|
47
|
+
res = Rack::Response.new(
|
48
|
+
response_body,
|
49
|
+
409,
|
50
|
+
{"fly-replay" => "region=#{Fly.configuration.primary_region};state=#{state}"}
|
51
|
+
)
|
52
|
+
res.finish
|
53
|
+
end
|
54
|
+
|
55
|
+
def within_replay_threshold?(threshold)
|
56
|
+
threshold && (threshold.to_i - Time.now.to_i) > 0
|
57
|
+
end
|
58
|
+
|
59
|
+
def replayable_http_method?(http_method)
|
60
|
+
Fly.configuration.replay_http_methods.include?(http_method)
|
61
|
+
end
|
62
|
+
|
63
|
+
def replay_request_state(header_value)
|
64
|
+
header_value&.scan(/(.*?)=(.*?)($|;)/)&.detect { |v| v[0] == "state" }&.at(1)
|
65
|
+
end
|
66
|
+
|
67
|
+
def call(env)
|
68
|
+
request = Rack::Request.new(env)
|
69
|
+
|
70
|
+
# Check whether this request satisfies any of the following conditions for replaying in the primary region:
|
71
|
+
#
|
72
|
+
# 1. Its HTTP method matches those configured for automatic replay (post/patch/put/delete by default).
|
73
|
+
# This approach should avoid potentially slow code execution - before_actions or other controller code -
|
74
|
+
# happening before a request reaches a database write.
|
75
|
+
# 2. It arrived before the threshold defined by the last write request. This threshold
|
76
|
+
# helps avoid the same client from missing its own write due to replication lag,
|
77
|
+
# like when a user adds to a todo list via XHR
|
78
|
+
|
79
|
+
if !in_primary_region?
|
80
|
+
if replayable_http_method?(request.request_method)
|
81
|
+
return replay_in_primary_region!(state: "http_method")
|
82
|
+
elsif within_replay_threshold?(request.cookies[Fly.configuration.replay_threshold_cookie])
|
83
|
+
return replay_in_primary_region!(state: "threshold")
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
begin
|
88
|
+
status, headers, body = @app.call(env)
|
89
|
+
rescue ActiveRecord::StatementInvalid => e
|
90
|
+
if e.cause.is_a?(PG::ReadOnlySqlTransaction)
|
91
|
+
return replay_in_primary_region!(state: "captured_write")
|
92
|
+
else
|
93
|
+
raise e
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
response = Rack::Response.new(body, status, headers)
|
98
|
+
replay_state = replay_request_state(request.get_header("HTTP_FLY_REPLAY_SRC"))
|
99
|
+
|
100
|
+
# Request was replayed, but not by a threshold
|
101
|
+
if replay_state && replay_state != "threshold"
|
102
|
+
response.set_cookie(
|
103
|
+
Fly.configuration.replay_threshold_cookie,
|
104
|
+
Time.now.to_i + Fly.configuration.replay_threshold_in_seconds
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
108
|
+
response.finish
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
metadata
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: fly-ruby
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Joshua Sierles
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-07-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.0'
|
27
|
+
description: Automate the work requied to run Ruby apps against region-local databases
|
28
|
+
on Fly.io
|
29
|
+
email: joshua@hey.com
|
30
|
+
executables: []
|
31
|
+
extensions: []
|
32
|
+
extra_rdoc_files: []
|
33
|
+
files:
|
34
|
+
- ".github/workflows/test.yml"
|
35
|
+
- ".gitignore"
|
36
|
+
- Gemfile
|
37
|
+
- LICENSE
|
38
|
+
- README.md
|
39
|
+
- Rakefile
|
40
|
+
- fly-ruby.gemspec
|
41
|
+
- lib/fly-ruby.rb
|
42
|
+
- lib/fly-ruby/configuration.rb
|
43
|
+
- lib/fly-ruby/railtie.rb
|
44
|
+
- lib/fly-ruby/regional_database.rb
|
45
|
+
homepage: https://github.com/superfly/fly-ruby
|
46
|
+
licenses:
|
47
|
+
- BSD-3-Clause
|
48
|
+
metadata: {}
|
49
|
+
post_install_message:
|
50
|
+
rdoc_options: []
|
51
|
+
require_paths:
|
52
|
+
- lib
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '2.4'
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '0'
|
63
|
+
requirements: []
|
64
|
+
rubygems_version: 3.2.3
|
65
|
+
signing_key:
|
66
|
+
specification_version: 4
|
67
|
+
summary: Augment Ruby web apps for deployment in Fly.io
|
68
|
+
test_files: []
|