fly-ruby 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,6 @@
1
+ tmp
2
+ log/
3
+ .bundle
4
+ Gemfile.lock
5
+ .ruby-version
6
+ *.gem
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'rack-test'
4
+ gem 'minitest'
5
+ gemspec
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
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << "test"
6
+ t.test_files = FileList['test/*.rb']
7
+ t.verbose = true
8
+ end
9
+
10
+ desc "Run tests"
11
+ task default: :test
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: []