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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82d6cde56b619737984124f61e7fc526129eebb493e1954f9b9b171202d8eed0
4
- data.tar.gz: 2a14fee50d07739be04a19476128671878fb448fbe1cf23654df254beba4507a
3
+ metadata.gz: 506556ce79da83a817f5a6a56b336328912730ace3fb26d10ed0d7e97f971abc
4
+ data.tar.gz: c2c7ceb044a6a9054da36ae775a0693458e91dac271b5d341666995dd59373b5
5
5
  SHA512:
6
- metadata.gz: 87c976601cddc300a435f503983f5de87e54c7965b07fd36232526c8e3da26e4ac2f40b729e6f701924d9371872adeaafd780937eb9a750af95c8f6cb59b9012
7
- data.tar.gz: 0c528dbb8a6d4035a754f8b580562c21194a37e999c64f82831aa2c70e0cb53b3687d9e94bbcaf67fe7ca1526f96232b87bb77a4dac1d017b30dcca080c9315d
6
+ metadata.gz: d7a79ba9b2a2db74515bdce0c606c99072d292813b5ef34c8963d44bcc0366fe0230bc784dcb8e489614ef1dd33ebb447d7cb215fd7f475c0fc00d51487446cf
7
+ data.tar.gz: 38e26c8853bde15845b1cd1c34bcff68f5f87db5a8e726af4451329619b54f3c4dfa26ffd83832e24ea0a743401b1e28c011316ab78b08c60cc76e42022bec58
@@ -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
- - { os: ubuntu-latest, ruby_version: jruby }
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
@@ -1,5 +1,11 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
+ gemspec
4
+
3
5
  gem 'rack-test'
4
6
  gem 'minitest'
5
- gemspec
7
+ gem "rails"
8
+ gem "pg"
9
+ gem "climate_control"
10
+ gem "minitest-around"
11
+ gem "m"
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
- # Augment Ruby web apps on Fly.io
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
- [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.
5
+ * Speed up apps by using region-local Postgresql replicas for database reads
6
6
 
7
- ## Regional replicas
7
+ ## Speed up apps using region-local database replicas
8
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.
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
- 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.
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 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.
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 replaying these 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
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 at the top of the Rack middleware stack.
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 custer may get out of sync.
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 database_url
39
- ENV[database_url_env_var]
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
@@ -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
- app.config.middleware.insert_after ActionDispatch::Executor, Fly::RegionalDatabase
5
- elsif !ENV["TESTING"]
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
- class RegionalDatabase
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
- response_body,
19
+ "",
62
20
  409,
63
- {"fly-replay" => "region=#{Fly.configuration.primary_region};state=#{state}"}
21
+ {"Fly-Replay" => "region=#{Fly.configuration.primary_region};state=#{state}"}
64
22
  )
65
23
  res.finish
66
24
  end
67
25
 
68
- def within_replay_threshold?(threshold)
69
- threshold && (threshold.to_i - Time.now.to_i) > 0
70
- end
26
+ class DbExceptionHandlerMiddleware
27
+ def initialize(app)
28
+ @app = app
29
+ end
71
30
 
72
- def replayable_http_method?(http_method)
73
- Fly.configuration.replay_http_methods.include?(http_method)
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
- def replay_request_state(header_value)
77
- header_value&.scan(/(.*?)=(.*?)($|;)/)&.detect { |v| v[0] == "state" }&.at(1)
78
- end
42
+ class ReplayableRequestMiddleware
43
+ def initialize(app)
44
+ @app = app
45
+ end
79
46
 
80
- def call(env)
81
- request = Rack::Request.new(env)
47
+ def within_replay_threshold?(threshold)
48
+ threshold && (threshold.to_i - Time.now.to_i) > 0
49
+ end
82
50
 
83
- # Check whether this request satisfies any of the following conditions for replaying in the primary region:
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
- begin
101
- status, headers, body = @app.call(env)
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
- response = Rack::Response.new(body, status, headers)
111
- replay_state = replay_request_state(request.get_header("HTTP_FLY_REPLAY_SRC"))
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
- # Request was replayed, but not by a threshold
114
- if replay_state && replay_state != "threshold"
115
- response.set_cookie(
116
- Fly.configuration.replay_threshold_cookie,
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
- response.finish
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
@@ -1,3 +1,3 @@
1
- class Fly
2
- VERSION = "0.1.1"
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.1.1
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-08 00:00:00.000000000 Z
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