├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── fly-ruby.gemspec ├── lib ├── fly-ruby.rb └── fly-ruby │ ├── configuration.rb │ ├── headers.rb │ ├── railtie.rb │ ├── regional_database.rb │ └── version.rb └── test ├── configuration_test.rb ├── fly_test.rb ├── rails_test.rb ├── regional_database_test.rb └── test_rails_app ├── app.rb ├── app └── assets │ └── config │ └── manifest.js ├── config.ru ├── config └── database.yml ├── db ├── public └── static.html └── test_template.html.erb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | name: Test on ruby ${{ matrix.ruby_version }} with options - ${{ toJson(matrix.options) }} 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | include: 10 | - { os: ubuntu-latest, ruby_version: 2.7 } 11 | - { os: ubuntu-latest, ruby_version: '3.0' } 12 | - { os: ubuntu-latest, ruby_version: 3.1 } 13 | services: 14 | # label used to access the service container 15 | postgres: 16 | # Docker Hub image 17 | image: postgres:latest 18 | # service environment variables 19 | # `POSTGRES_HOST` is `postgres` 20 | env: 21 | # optional (defaults to `postgres`) 22 | POSTGRES_DB: fly_ruby_test 23 | # required 24 | POSTGRES_PASSWORD: postgres_password 25 | # optional (defaults to `5432`) 26 | POSTGRES_PORT: 5432 27 | # optional (defaults to `postgres`) 28 | POSTGRES_USER: postgres_user 29 | ports: 30 | # maps tcp port 5432 on service container to the host 31 | - 5432:5432 32 | # set health checks to wait until postgres has started 33 | options: >- 34 | --health-cmd pg_isready 35 | --health-interval 10s 36 | --health-timeout 5s 37 | --health-retries 5 38 | steps: 39 | - name: Setup Ruby, JRuby and TruffleRuby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | bundler: 1 43 | ruby-version: ${{ matrix.ruby_version }} 44 | - name: Checkout code 45 | uses: actions/checkout@v3 46 | - name: Run tests 47 | env: 48 | DATABASE_USER: postgres_user 49 | run: | 50 | bundle install --jobs 4 --retry 3 51 | rake 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | log/ 3 | .bundle 4 | Gemfile.lock 5 | .ruby-version 6 | *.gem 7 | db/ 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 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 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'rack-test' 6 | gem 'minitest' 7 | gem "rails" 8 | gem "pg" 9 | gem "sqlite3" 10 | gem "climate_control" 11 | gem "minitest-around" 12 | gem "m" 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | 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 | 5 | https://fly.io/blog/run-ordinary-rails-apps-globally 6 | 7 | ## Speed up apps using region-local database replicas 8 | 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 | 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 | 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. 14 | 15 | Currently, it does this by: 16 | 17 | * modifying the `DATABASE_URL` to point apps to their local regional replica 18 | * replaying non-idempotent (post/put/patch/delete) requests in the primary region 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 23 | 24 | ## Requirements 25 | 26 | You should have [setup a postgres cluster](https://fly.io/docs/getting-started/multi-region-databases/) on Fly. Then: 27 | 28 | * ensure that your Postgresql and application regions match up 29 | * ensure that no backup regions are assigned to your application 30 | * attach the Postgres cluster to your application with `fly postgres attach` 31 | 32 | Finally, set the `PRIMARY_REGION` environment variable in your app `fly.toml` to match the primary database region. 33 | 34 | ## Installation 35 | 36 | Add to your Gemfile and `bundle install`: 37 | 38 | `gem "fly-ruby"` 39 | 40 | If you're on Rails, the middleware will insert itself automatically, and attempt to reconnect the database. 41 | 42 | ## Configuration 43 | 44 | Most values used by this middleware are configurable. On Rails, this might go in an initializer like `config/initializers/fly.rb` 45 | 46 | ``` 47 | Fly.configure do |c| 48 | c.replay_threshold_in_seconds = 10 49 | end 50 | ``` 51 | 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 | ## Known issues 54 | 55 | This middleware send all requests to the primary if you do something like update a user's database session on every GET request. 56 | 57 | If your replica becomes writeable for some reason, your cluster may get out of sync. 58 | 59 | ## TODO 60 | 61 | Here are some ideas for improving this gem. 62 | 63 | * 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 64 | 65 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require_relative "lib/fly-ruby/version" 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.test_files = FileList['test/*.rb'] 8 | t.verbose = true 9 | end 10 | 11 | desc "Run tests" 12 | task default: :test 13 | 14 | task :top do 15 | puts Rake.application.top_level_tasks 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 26 | -------------------------------------------------------------------------------- /fly-ruby.gemspec: -------------------------------------------------------------------------------- 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 18 | -------------------------------------------------------------------------------- /lib/fly-ruby.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "fly-ruby/configuration" 4 | require_relative "fly-ruby/regional_database" 5 | require_relative "fly-ruby/headers" 6 | 7 | require "forwardable" 8 | 9 | if defined?(::Rails) 10 | require_relative "fly-ruby/railtie" 11 | end 12 | 13 | module Fly 14 | class << self 15 | extend Forwardable 16 | 17 | def instance 18 | @instance ||= Instance.new 19 | end 20 | 21 | def_delegators :instance, :configuration, :configuration=, :configure 22 | end 23 | 24 | class Instance 25 | attr_writer :configuration 26 | 27 | def configuration 28 | @configuration ||= Fly::Configuration.new 29 | end 30 | 31 | def configure(&block) 32 | configuration.tap(&block) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/fly-ruby/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fly 4 | class Configuration 5 | # Set the region where this instance of the application is deployed 6 | attr_accessor :current_region 7 | 8 | # Set the region where the primary database lives, i.e "ams" 9 | attr_accessor :primary_region 10 | 11 | # Automatically replay these HTTP methods in the primary region 12 | attr_accessor :replay_http_methods 13 | 14 | # Environment variables related to the database connection. 15 | # These get by this middleware in secondary regions, so they must be interpolated 16 | # rather than defined directly in the configuration. 17 | attr_accessor :database_url_env_var 18 | attr_accessor :database_host_env_var 19 | attr_accessor :database_port_env_var 20 | attr_accessor :redis_url_env_var 21 | 22 | # Cookie written and read by this middleware storing a UNIX timestamp. 23 | # Requests arriving before this timestamp will be replayed in the primary region. 24 | attr_accessor :replay_threshold_cookie 25 | 26 | # How long, in seconds, should all requests from the same client be replayed in the 27 | # primary region after a successful write replay 28 | attr_accessor :replay_threshold_in_seconds 29 | 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 35 | 36 | def initialize 37 | self.primary_region = ENV["PRIMARY_REGION"] 38 | self.current_region = ENV["FLY_REGION"] 39 | self.replay_http_methods = ["POST", "PUT", "PATCH", "DELETE"] 40 | self.database_url_env_var = "DATABASE_URL" 41 | self.redis_url_env_var = "REDIS_URL" 42 | self.database_host_env_var = "DATABASE_HOST" 43 | self.database_port_env_var = "DATABASE_PORT" 44 | self.replay_threshold_cookie = "fly-replay-threshold" 45 | self.replay_threshold_in_seconds = 5 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 | end 66 | 67 | def database_app_name 68 | database_uri.hostname.split(".")[-2] || database_uri.hostname 69 | end 70 | 71 | def database_domain 72 | "#{database_app_name}.internal" 73 | end 74 | 75 | def primary_database_url 76 | uri = database_uri.dup 77 | uri.host = "#{primary_region}.#{database_domain}" 78 | uri.port = secondary_database_port 79 | uri.to_s 80 | end 81 | 82 | def secondary_database_url 83 | uri = database_uri.dup 84 | uri.host = "top1.nearest.of.#{database_domain}" 85 | uri.port = secondary_database_port 86 | uri.to_s 87 | end 88 | 89 | def secondary_database_port 90 | port = if in_secondary_region? 91 | case database_uri.scheme 92 | when "postgres" 93 | 5433 94 | end 95 | end 96 | 97 | port || database_uri.port 98 | end 99 | 100 | def redis_uri 101 | @redis_uri ||= URI.parse(redis_url) 102 | @redis_uri 103 | end 104 | 105 | def regional_redis_host 106 | "#{current_region}.#{redis_uri.hostname}" 107 | end 108 | 109 | def regional_redis_url 110 | uri = redis_uri.dup 111 | uri.host = regional_redis_host 112 | uri.to_s 113 | end 114 | 115 | def eligible_for_activation? 116 | database_url && primary_region && current_region && !rake_task? 117 | end 118 | 119 | def hijack_database_connection! 120 | # Don't reset the database URL for on-disk sqlite 121 | return if database_uri.scheme.start_with?("sqlite") || database_uri.host !~ /(internal|localhost)/ 122 | ENV["DATABASE_URL"] = in_secondary_region? ? secondary_database_url : primary_database_url 123 | end 124 | 125 | def in_secondary_region? 126 | primary_region && primary_region != current_region 127 | end 128 | 129 | # Is the current process a Rails console? 130 | def console? 131 | defined?(::Rails::Console) && $stdout.isatty && $stdin.isatty 132 | end 133 | 134 | # Is the current process a rake task? 135 | def rake_task? 136 | defined?(::Rake) && !Rake.application.top_level_tasks.empty? 137 | end 138 | 139 | def web? 140 | !console? && !rake_task? 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/fly-ruby/headers.rb: -------------------------------------------------------------------------------- 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 17 | -------------------------------------------------------------------------------- /lib/fly-ruby/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Fly::Railtie < Rails::Railtie 4 | initializer("fly.regional_database", before: "active_record.initialize_database") do |app| 5 | # Insert the request middleware high in the stack, but after static file delivery 6 | app.config.middleware.insert_after ActionDispatch::Executor, Fly::Headers 7 | 8 | if Fly.configuration.eligible_for_activation? 9 | 10 | Fly.configuration.hijack_database_connection! 11 | 12 | app.config.middleware.insert_after Fly::Headers, Fly::RegionalDatabase::ReplayableRequestMiddleware 13 | # Insert the database exception handler at the bottom of the stack to take priority over other exception handlers 14 | app.config.middleware.use Fly::RegionalDatabase::DbExceptionHandlerMiddleware 15 | 16 | elsif Fly.configuration.web? 17 | puts "Warning: DATABASE_URL, PRIMARY_REGION and FLY_REGION must be set to activate the fly-ruby middleware. Middleware not loaded." 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/fly-ruby/regional_database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rake' 4 | 5 | module Fly 6 | # Note that using instance variables in Rack middleware is considered a poor practice in 7 | # multithreaded environments. Instead of using dirty tricks like using Object#dup, 8 | # values are passed to methods. 9 | 10 | module RegionalDatabase 11 | # Stop the current request and ask for it to be replayed in the primary region. 12 | # Pass one of three states to the target region, to determine how to handle the request: 13 | # 14 | # Possible states: captured_write, http_method, threshold 15 | # captured_write: A write was rejected by the database 16 | # http_method: A non-idempotent HTTP method was replayed before hitting the application 17 | # threshold: A recent write set a threshold during which all requests are replayed 18 | 19 | def self.replay_in_primary_region!(state:) 20 | res = Rack::Response.new( 21 | "", 22 | 409, 23 | {"Fly-Replay" => "region=#{Fly.configuration.primary_region};state=#{state}"} 24 | ) 25 | res.finish 26 | end 27 | 28 | class DbExceptionHandlerMiddleware 29 | def initialize(app) 30 | @app = app 31 | end 32 | 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 43 | end 44 | 45 | class ReplayableRequestMiddleware 46 | def initialize(app) 47 | @app = app 48 | end 49 | 50 | def within_replay_threshold?(threshold) 51 | threshold && (threshold.to_i - Time.now.to_i) > 0 52 | end 53 | 54 | def replayable_http_method?(http_method) 55 | Fly.configuration.replay_http_methods.include?(http_method) 56 | end 57 | 58 | def replay_request_state(header_value) 59 | header_value&.slice(/(?:^|;)state=([^;]*)/, 1) 60 | end 61 | 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 79 | 80 | status, headers, body = @app.call(env) 81 | 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 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/fly-ruby/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fly 4 | VERSION = "0.5.1" 5 | end 6 | -------------------------------------------------------------------------------- /test/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require "rack/test" 2 | require "minitest/autorun" 3 | require_relative "../lib/fly-ruby" 4 | 5 | ENV["TESTING"] = "1" 6 | 7 | class ConfigurationTest < Minitest::Test 8 | def setup 9 | ENV["REDIS_URL"] = "redis://redis.internal:6379" 10 | ENV["DATABASE_URL"] = "postgresql://db.internal:6379" 11 | ENV["FLY_REGION"] = "iad" 12 | ENV["PRIMARY_REGION"] = "iad" 13 | @configuration = Fly::Configuration.new 14 | end 15 | 16 | def test_regional_redis_config 17 | assert_equal "redis://iad.redis.internal:6379", @configuration.regional_redis_url 18 | assert_equal "iad.redis.internal", @configuration.regional_redis_host 19 | end 20 | 21 | def test_regional_database_config 22 | assert_equal "postgresql://top1.nearest.of.db.internal:6379", @configuration.secondary_database_url 23 | assert_equal "postgresql://iad.db.internal:6379", @configuration.primary_database_url 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/fly_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require_relative "../lib/fly-ruby" 3 | 4 | ENV["TESTING"] = "1" 5 | 6 | class FlyTest < Minitest::Test 7 | def test_configuration 8 | assert_kind_of Fly::Configuration, Fly.configuration 9 | end 10 | 11 | def test_configuration_can_be_reset 12 | old_configuration = Fly.configuration 13 | Fly.configuration = nil 14 | 15 | assert_kind_of Fly::Configuration, Fly.configuration 16 | refute_same old_configuration, Fly.configuration 17 | end 18 | 19 | def test_configure 20 | configuration_from_block = nil 21 | Fly.configure { |configuration| configuration_from_block = configuration } 22 | 23 | assert_same Fly.configuration, configuration_from_block 24 | end 25 | 26 | def test_configure_preserves_configuration 27 | configuration_before_block = Fly.configuration 28 | Fly.configure { } 29 | 30 | assert_same configuration_before_block, Fly.configuration 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/rails_test.rb: -------------------------------------------------------------------------------- 1 | require "rack/test" 2 | require "minitest/autorun" 3 | require "bundler/setup" 4 | require "climate_control" 5 | require "minitest/around/unit" 6 | require "active_support/testing/isolation" 7 | 8 | require_relative "test_rails_app/app" 9 | 10 | POSTGRES_HOST = ENV["DATABASE_HOST"] || "localhost" 11 | 12 | class TestFlyRails < Minitest::Test 13 | include ActiveSupport::Testing::Isolation 14 | include Rack::Test::Methods 15 | 16 | attr_reader :app 17 | 18 | def setup 19 | ENV["DATABASE_URL"] = "postgres://#{POSTGRES_HOST}:5432/fly_ruby_test" 20 | ENV["PRIMARY_REGION"] = "iad" 21 | ENV["FLY_REGION"] = "ams" 22 | Fly.configuration = nil 23 | @app = make_basic_app 24 | end 25 | 26 | def test_middleware_inserted_with_required_env_vars 27 | index_of_executor = @app.middleware.find_index { |m| m == ActionDispatch::Executor } 28 | assert_equal index_of_executor + 1, @app.middleware.find_index(Fly::Headers) 29 | assert_equal index_of_executor + 2, @app.middleware.find_index(Fly::RegionalDatabase::ReplayableRequestMiddleware) 30 | assert_equal @app.middleware.size - 1, @app.middleware.find_index(Fly::RegionalDatabase::DbExceptionHandlerMiddleware) 31 | end 32 | 33 | def test_database_configuration_is_overridden 34 | config = ActiveRecord::Base.connection_db_config.configuration_hash 35 | assert_equal "top1.nearest.of.#{POSTGRES_HOST}.internal", config[:host] 36 | assert_equal 5433, config[:port] 37 | end 38 | 39 | def test_database_configuration_is_overridden_when_connection_reestablished 40 | ActiveRecord::Base.establish_connection({ url: "postgres://#{POSTGRES_HOST}:5432/fly_ruby_test" }) 41 | config = ActiveRecord::Base.connection_db_config.configuration_hash 42 | assert_equal POSTGRES_HOST, config[:host] 43 | assert_equal 5432, config[:port] 44 | 45 | ActiveRecord::Base.establish_connection 46 | config = ActiveRecord::Base.connection_db_config.configuration_hash 47 | assert_equal "top1.nearest.of.#{POSTGRES_HOST}.internal", config[:host] 48 | assert_equal 5433, config[:port] 49 | end 50 | 51 | def test_debug_headers_are_appended_to_responses 52 | get "/" 53 | assert_equal "ams", last_response.headers["Fly-Region"] 54 | end 55 | 56 | def test_post_gets_replayed 57 | post "/world" 58 | assert last_response.headers['Fly-Replay'] 59 | end 60 | 61 | def test_database_write_exception_gets_replayed 62 | get "/exception" 63 | assert last_response.headers["Fly-Replay"] =~ /captured_write/ 64 | end 65 | end 66 | 67 | class TestFlyRailsPrimary < Minitest::Test 68 | include ActiveSupport::Testing::Isolation 69 | include Rack::Test::Methods 70 | 71 | attr_reader :app 72 | 73 | def setup 74 | ENV["DATABASE_URL"] = "postgres://#{POSTGRES_HOST}:5432/fly_ruby_test" 75 | ENV["PRIMARY_REGION"] = "iad" 76 | ENV["FLY_REGION"] = "iad" 77 | Fly.configuration = nil 78 | @app = make_basic_app 79 | end 80 | 81 | def test_database_configuration_is_overridden 82 | config = ActiveRecord::Base.connection_db_config.configuration_hash 83 | assert_equal "iad.#{POSTGRES_HOST}.internal", config[:host] 84 | end 85 | 86 | def test_database_configuration_is_overridden_when_connection_reestablished 87 | ActiveRecord::Base.establish_connection({ url: "postgres://#{POSTGRES_HOST}:5432/fly_ruby_test" }) 88 | config = ActiveRecord::Base.connection_db_config.configuration_hash 89 | assert_equal POSTGRES_HOST, config[:host] 90 | 91 | ActiveRecord::Base.establish_connection 92 | config = ActiveRecord::Base.connection_db_config.configuration_hash 93 | assert_equal "iad.#{POSTGRES_HOST}.internal", config[:host] 94 | end 95 | end 96 | 97 | class TestFlyRailsAlternativeEnvironments < Minitest::Test 98 | include ActiveSupport::Testing::Isolation 99 | 100 | def setup 101 | Fly.configuration = nil 102 | end 103 | 104 | def test_middleware_skipped_without_required_env_vars 105 | ENV["PRIMARY_REGION"] = nil 106 | 107 | assert_output %r/middleware not loaded/i do 108 | make_basic_app 109 | end 110 | refute Rails.application.middleware.find_index(Fly::RegionalDatabase) 111 | end 112 | 113 | def test_database_connection_not_hijacked_when_using_sqlite 114 | ENV["DATABASE_URL"] = "sqlite3://foo" 115 | ENV["PRIMARY_REGION"] = "iad" 116 | ENV["FLY_REGION"] = "ams" 117 | make_basic_app 118 | 119 | config = ActiveRecord::Base.connection_db_config.configuration_hash 120 | assert_equal "foo", config[:host] 121 | assert_nil config[:port] 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/regional_database_test.rb: -------------------------------------------------------------------------------- 1 | require "rack/test" 2 | require "minitest/autorun" 3 | require_relative "../lib/fly-ruby" 4 | 5 | ENV["TESTING"] = "1" 6 | 7 | class RegionalDatabaseTest < Minitest::Test 8 | include Rack::Test::Methods 9 | 10 | def setup 11 | ENV['DATABASE_URL'] = 'postgres://localhost:5432' 12 | Fly.configuration = nil 13 | Fly.configure do |config| 14 | config.primary_region = "iad" 15 | config.current_region = "ams" 16 | end 17 | end 18 | 19 | def app 20 | app = lambda { |env| [200, {"Content-Type" => "text/plain"}, ["OK"]] } 21 | Fly::RegionalDatabase::ReplayableRequestMiddleware.new(app) 22 | end 23 | 24 | def test_get_request_wont_replay_or_set_cookies 25 | get "/" 26 | assert last_response.ok? 27 | refute last_response.cookies[Fly.configuration.replay_threshold_cookie] 28 | end 29 | 30 | def test_post_request_will_replay_on_secondary_region 31 | post "/" 32 | assert_replayed("http_method") 33 | end 34 | 35 | def test_replayed_request_will_send_next_get_request_to_primary 36 | simulate_replayed_post("captured_write") 37 | assert last_response.ok? 38 | assert last_response.cookies[Fly.configuration.replay_threshold_cookie].value.first.to_i > Time.now.to_i 39 | simulate_secondary_get 40 | assert_replayed("threshold") 41 | refute last_response.cookies[Fly.configuration.replay_threshold_cookie] 42 | end 43 | 44 | def test_threshold_replayed_request_will_not_reset_threshold_cookie 45 | simulate_replayed_post("captured_write") 46 | simulate_secondary_get 47 | simulate_replayed_post("threshold") 48 | refute last_response.cookies[Fly.configuration.replay_threshold_cookie] 49 | end 50 | 51 | def simulate_replayed_post(state) 52 | Fly.configuration.current_region = Fly.configuration.primary_region 53 | header "Fly-Replay-Src", "state=#{state}" 54 | post "/" 55 | end 56 | 57 | def simulate_secondary_get 58 | Fly.configuration.current_region = "ams" 59 | get "/" 60 | end 61 | 62 | def assert_replayed(state) 63 | assert_equal 409, last_response.status 64 | assert_equal "region=iad;state=#{state}", last_response.headers["Fly-Replay"] 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/test_rails_app/app.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | require "active_record" 3 | require "active_record/railtie" 4 | require "action_view/railtie" 5 | require "action_controller/railtie" 6 | 7 | require_relative "../../lib/fly-ruby/railtie" 8 | 9 | # Bare bones Rails app, borrowed from the mighty sentry-rails gem 10 | class TestApp < Rails::Application;end 11 | 12 | ActiveRecord::Base.establish_connection( 13 | adapter: "postgresql", 14 | database: "fly_ruby_test", 15 | host: ENV['DATABASE_HOST'] || 'localhost', 16 | port: "5432", 17 | username: ENV['DATABASE_USER'], 18 | password: "postgres_password" 19 | ) 20 | 21 | ActiveRecord::Base.logger = Logger.new(nil) 22 | 23 | ActiveRecord::Schema.define do 24 | create_table :posts, force: true do |t| 25 | end 26 | 27 | create_table :comments, force: true do |t| 28 | t.integer :post_id 29 | end 30 | end 31 | 32 | class Post < ActiveRecord::Base 33 | has_many :comments 34 | end 35 | 36 | class Comment < ActiveRecord::Base 37 | belongs_to :post 38 | end 39 | 40 | class ApplicationController < ActionController::Base; end 41 | 42 | class PostsController < ApplicationController 43 | def index 44 | Post.all.to_a 45 | raise "foo" 46 | end 47 | 48 | def show 49 | p = Post.find(params[:id]) 50 | 51 | render plain: p.id 52 | end 53 | end 54 | 55 | class HelloController < ApplicationController 56 | prepend_view_path "spec/support/test_rails_app" 57 | 58 | def exception 59 | raise PG::ReadOnlySqlTransaction 60 | end 61 | 62 | def view 63 | render template: "test_template" 64 | end 65 | 66 | def world 67 | render plain: "Hello World!" 68 | end 69 | 70 | def not_found 71 | raise ActionController::BadRequest 72 | end 73 | end 74 | 75 | def make_basic_app 76 | app = Class.new(TestApp) do 77 | def self.name 78 | "RailsTestApp" 79 | end 80 | end 81 | 82 | app.config.load_defaults Rails::VERSION::STRING.to_f 83 | 84 | app.config.hosts = nil 85 | app.config.secret_key_base = "test" 86 | 87 | # Usually set for us in production.rb 88 | app.config.eager_load = true 89 | app.routes.append do 90 | get "/exception", to: "hello#exception" 91 | get "/view", to: "hello#view" 92 | get "/not_found", to: "hello#not_found" 93 | get "/world", to: "hello#world" 94 | post "/world", to: "hello#world" 95 | resources :posts, only: [:index, :show] 96 | root to: "hello#world" 97 | end 98 | 99 | app.initializer :configure_release do 100 | Fly.configure do |config| 101 | config.replay_threshold_in_seconds = 5 102 | end 103 | end 104 | app.initialize! 105 | 106 | Rails.application = app 107 | app 108 | end 109 | -------------------------------------------------------------------------------- /test/test_rails_app/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/fly-ruby/f7297b0af57eb4b8e0292d52876788565e442fe5/test/test_rails_app/app/assets/config/manifest.js -------------------------------------------------------------------------------- /test/test_rails_app/config.ru: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/fly-ruby/f7297b0af57eb4b8e0292d52876788565e442fe5/test/test_rails_app/config.ru -------------------------------------------------------------------------------- /test/test_rails_app/config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgresql 3 | -------------------------------------------------------------------------------- /test/test_rails_app/db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/fly-ruby/f7297b0af57eb4b8e0292d52876788565e442fe5/test/test_rails_app/db -------------------------------------------------------------------------------- /test/test_rails_app/public/static.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/superfly/fly-ruby/f7297b0af57eb4b8e0292d52876788565e442fe5/test/test_rails_app/public/static.html -------------------------------------------------------------------------------- /test/test_rails_app/test_template.html.erb: -------------------------------------------------------------------------------- 1 | "foo" 2 | --------------------------------------------------------------------------------